From 3dd351458cf9c5398dd9f78a12a8a0f4b66033a2 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 14:54:51 -0400 Subject: [PATCH 1/6] refactor(cli): restructure flat 52-command surface into 12 subcommand groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI is becoming the primary agent tool surface (replacing MCP), but 52 commands in a flat alphabetical list is unusable for discovery. This restructure groups them hierarchically so `sprout --help` shows 12 domain groups and `sprout --help` shows that group's subcommands with descriptions and examples. Breaking change — zero external consumers exist today. Key migrations: `sprout send-message` -> `sprout messages send`, `sprout approve-step --approved true` -> `sprout workflows approve`. Also deduplicates parse_event_id/parse_uuid into validate.rs, inlines the vestigial sign_and_submit_builder wrapper in channels.rs, and converts --approved from string to proper boolean flag. --- crates/sprout-cli/README.md | 166 +-- crates/sprout-cli/TESTING.md | 466 ++++---- crates/sprout-cli/src/commands/channels.rs | 41 +- crates/sprout-cli/src/commands/messages.rs | 16 +- crates/sprout-cli/src/commands/social.rs | 8 +- crates/sprout-cli/src/lib.rs | 1141 +++++++++++--------- crates/sprout-cli/src/validate.rs | 38 + 7 files changed, 977 insertions(+), 899 deletions(-) diff --git a/crates/sprout-cli/README.md b/crates/sprout-cli/README.md index 51cd86662..e1e2078c0 100644 --- a/crates/sprout-cli/README.md +++ b/crates/sprout-cli/README.md @@ -20,11 +20,11 @@ Two modes, checked in order: ```bash # Option 1: Private key (NIP-98 signed requests) export SPROUT_PRIVATE_KEY="nsec1..." -sprout list-channels +sprout channels list # Option 2: Dev mode (no auth) export SPROUT_PUBKEY="" -sprout list-channels +sprout channels list ``` ## Usage @@ -36,106 +36,116 @@ All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=use export SPROUT_RELAY_URL="https://relay.example.com" # Messages -sprout send-message --channel --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 --approved # 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-note` | Publish a NIP-01 note | +| | `set-contact-list` | Set NIP-02 contact list | +| | `get-event` | Get a Nostr event | +| | `get-user-notes` | Get notes for a user | +| | `get-contact-list` | Get NIP-02 contact list | +| `repos` | `create` | Announce a git repository (NIP-34) | +| | `get` | Get a repository announcement | +| | `list` | List repository announcements | +| `upload` | `file` | Upload a file to the Blossom store | +| `pack` | `validate` | Validate a persona pack (local, no relay) | +| | `inspect` | Inspect a persona pack (local, no relay) | ## Architecture ``` -sprout [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..5dcc82411 100644 --- a/crates/sprout-cli/TESTING.md +++ b/crates/sprout-cli/TESTING.md @@ -71,44 +71,28 @@ cargo run -p sprout-admin -- mint-token \ This generates a keypair and prints: - **Private key (nsec)** — save for `SPROUT_PRIVATE_KEY` testing -- **API Token** — save as `SPROUT_API_TOKEN` - **Pubkey** — save for `SPROUT_PUBKEY` testing Export: ```bash export SPROUT_RELAY_URL="http://localhost:3000" -export SPROUT_API_TOKEN="sprout_tok_..." export SPROUT_PRIVATE_KEY="nsec1..." # from the mint output ``` -### Option B: sprout auth (NIP-98, self-mintable scopes only) - -Tests the CLI's own auth flow. Cannot mint `admin:channels`. - -```bash -export SPROUT_PRIVATE_KEY="nsec1..." -export SPROUT_RELAY_URL="http://localhost:3000" -cargo run -p sprout-cli -- auth -# Prints a token string to stdout -``` - ### Scope reference | Scope | Self-mintable | Needed for | |-------|:---:|------------| -| `messages:read` | ✅ | get-messages, get-thread, search, get-feed | -| `messages:write` | ✅ | send-message, edit-message, delete-message, reactions, vote | -| `channels:read` | ✅ | list-channels, get-channel, list-members | -| `channels:write` | ✅ | create-channel, update-channel, join, leave, topic, purpose | -| `users:read` | ✅ | get-users, get-presence | -| `users:write` | ✅ | set-profile, set-presence, set-channel-add-policy | +| `messages:read` | ✅ | `messages get`, `messages thread`, `messages search`, `feed get` | +| `messages:write` | ✅ | `messages send`, `messages edit`, `messages delete`, `reactions`, `messages vote` | +| `channels:read` | ✅ | `channels list`, `channels get`, `channels members` | +| `channels:write` | ✅ | `channels create`, `channels update`, `channels join`, `channels leave`, `channels topic`, `channels purpose` | +| `users:read` | ✅ | `users get`, `users presence` | +| `users:write` | ✅ | `users set-profile`, `users set-presence` | | `files:read` | ✅ | — | | `files:write` | ✅ | — | -| `admin:channels` | ❌ | archive, unarchive, delete-channel, add/remove-channel-member | - -**Use Option A for full testing.** Option B covers most commands but skips -admin operations. +| `admin:channels` | ❌ | `channels archive`, `channels unarchive`, `channels delete`, `channels add-member`, `channels remove-member` | --- @@ -127,147 +111,120 @@ cargo clippy -p sprout-cli -- -D warnings ## 6. Live Testing — Command by Command Run each command, verify exit code 0 and check output. Most commands -return JSON (pipe through `jq .` to validate). Exceptions: `auth` prints -a raw token string, and `delete-token`/`delete-all-tokens` may return -empty (204). Commands are ordered so earlier ones create resources that -later ones need. +return JSON (pipe through `jq .` to validate). Commands are ordered so +earlier ones create resources that later ones need. -### 6.1 Auth & Tokens +### 6.1 Channels ```bash -# list-tokens — list existing tokens -sprout list-tokens | jq . - -# auth — mint a new token (requires SPROUT_PRIVATE_KEY) -SPROUT_PRIVATE_KEY="nsec1..." sprout auth -# Should print: sprout_tok_... - -# delete-token — delete a specific token by UUID -# ⚠️ Do NOT delete the token you're currently using (SPROUT_API_TOKEN). -# Mint a throwaway token first, then delete it: -THROWAWAY=$(sprout auth) # mint a new token -THROWAWAY_LIST=$(SPROUT_API_TOKEN="$THROWAWAY" sprout list-tokens) -# Filter by name to avoid deleting the wrong token -THROWAWAY_ID=$(echo "$THROWAWAY_LIST" | jq -r '[.[] // .tokens[] | select(.name == "sprout-cli")][0].id // empty') -sprout delete-token --id "$THROWAWAY_ID" -# May return 204 (empty) or JSON — both are success - -# delete-all-tokens — DESTRUCTIVE, deletes all tokens for this pubkey -# sprout delete-all-tokens -# ⚠️ Only run this if you're about to re-mint -``` - -### 6.2 Channels - -```bash -# create-channel (stream) -sprout create-channel --name "test-stream" --type stream --visibility open \ +# channels create (stream) +sprout channels create --name "test-stream" --type stream --visibility open \ --description "CLI test channel" | jq . # Save the channel ID: -CHANNEL_ID=$(sprout create-channel --name "test-cli" --type stream --visibility open | jq -r '.id') +CHANNEL_ID=$(sprout channels create --name "test-cli" --type stream --visibility open | jq -r '.id') -# create-channel (forum) — needed for vote-on-post later -FORUM_ID=$(sprout create-channel --name "test-forum" --type forum --visibility open | jq -r '.id') +# channels create (forum) — needed for messages vote later +FORUM_ID=$(sprout channels create --name "test-forum" --type forum --visibility open | jq -r '.id') -# list-channels -sprout list-channels | jq . -sprout list-channels --visibility open | jq . -sprout list-channels --member | jq . +# channels list +sprout channels list | jq . +sprout channels list --visibility open | jq . +sprout channels list --member | jq . -# get-channel -sprout get-channel --channel "$CHANNEL_ID" | jq . +# channels get +sprout channels get --channel "$CHANNEL_ID" | jq . -# update-channel -sprout update-channel --channel "$CHANNEL_ID" --name "test-cli-updated" \ +# channels update +sprout channels update --channel "$CHANNEL_ID" --name "test-cli-updated" \ --description "Updated" | jq . -# set-channel-topic -sprout set-channel-topic --channel "$CHANNEL_ID" --topic "Test topic" | jq . +# channels topic +sprout channels topic --channel "$CHANNEL_ID" --topic "Test topic" | jq . -# set-channel-purpose -sprout set-channel-purpose --channel "$CHANNEL_ID" --purpose "Testing" | jq . +# channels purpose +sprout channels purpose --channel "$CHANNEL_ID" --purpose "Testing" | jq . -# join-channel (may already be a member from create) -sprout join-channel --channel "$CHANNEL_ID" | jq . +# channels join (may already be a member from create) +sprout channels join --channel "$CHANNEL_ID" | jq . -# leave-channel -sprout leave-channel --channel "$CHANNEL_ID" | jq . +# channels leave +sprout channels leave --channel "$CHANNEL_ID" | jq . # Re-join so we can send messages -sprout join-channel --channel "$CHANNEL_ID" | jq . +sprout channels join --channel "$CHANNEL_ID" | jq . -# archive-channel (requires admin:channels scope) -sprout archive-channel --channel "$CHANNEL_ID" | jq . +# channels archive (requires admin:channels scope) +sprout channels archive --channel "$CHANNEL_ID" | jq . -# unarchive-channel -sprout unarchive-channel --channel "$CHANNEL_ID" | jq . +# channels unarchive +sprout channels unarchive --channel "$CHANNEL_ID" | jq . ``` -### 6.3 Canvas +### 6.2 Canvas ```bash -# set-canvas -sprout set-canvas --channel "$CHANNEL_ID" --content "# Test Canvas" | jq . +# canvas set +sprout canvas set --channel "$CHANNEL_ID" --content "# Test Canvas" | jq . -# set-canvas from stdin -echo "# Canvas from stdin" | sprout set-canvas --channel "$CHANNEL_ID" --content - | jq . +# canvas set from stdin +echo "# Canvas from stdin" | sprout canvas set --channel "$CHANNEL_ID" --content - | jq . -# get-canvas -sprout get-canvas --channel "$CHANNEL_ID" | jq . +# canvas get +sprout canvas get --channel "$CHANNEL_ID" | jq . ``` -### 6.4 Messages +### 6.3 Messages ```bash -# send-message -MSG=$(sprout send-message --channel "$CHANNEL_ID" --content "Hello from CLI test" | jq .) +# messages send +MSG=$(sprout messages send --channel "$CHANNEL_ID" --content "Hello from CLI test" | jq .) echo "$MSG" EVENT_ID=$(echo "$MSG" | jq -r '.id // .event_id') -# send-message with reply + broadcast -REPLY=$(sprout send-message --channel "$CHANNEL_ID" --content "Reply" \ +# messages send with reply + broadcast +REPLY=$(sprout messages send --channel "$CHANNEL_ID" --content "Reply" \ --reply-to "$EVENT_ID" --broadcast | jq .) echo "$REPLY" REPLY_ID=$(echo "$REPLY" | jq -r '.id // .event_id') -# send-message with mentions -sprout send-message --channel "$CHANNEL_ID" --content "Hey @someone" \ +# messages send with mentions +sprout messages send --channel "$CHANNEL_ID" --content "Hey @someone" \ --mention "0000000000000000000000000000000000000000000000000000000000000001" | jq . -# get-messages -sprout get-messages --channel "$CHANNEL_ID" | jq . -sprout get-messages --channel "$CHANNEL_ID" --limit 5 | jq . +# messages get +sprout messages get --channel "$CHANNEL_ID" | jq . +sprout messages get --channel "$CHANNEL_ID" --limit 5 | jq . -# get-thread -sprout get-thread --channel "$CHANNEL_ID" --event "$EVENT_ID" | jq . +# messages thread +sprout messages thread --channel "$CHANNEL_ID" --event "$EVENT_ID" | jq . -# search -sprout search --query "Hello" | jq . -sprout search --query "CLI test" --limit 5 | jq . +# messages search +sprout messages search --query "Hello" | jq . +sprout messages search --query "CLI test" --limit 5 | jq . -# edit-message -sprout edit-message --event "$EVENT_ID" --content "Edited by CLI test" | jq . +# messages edit +sprout messages edit --event "$EVENT_ID" --content "Edited by CLI test" | jq . -# delete-message -sprout delete-message --event "$REPLY_ID" | jq . +# messages delete +sprout messages delete --event "$REPLY_ID" | jq . ``` -### 6.5 Diff Messages +### 6.4 Diff Messages ```bash -# send-diff-message from stdin +# messages send-diff from stdin echo '--- a/foo.rs +++ b/foo.rs @@ -1,3 +1,3 @@ -fn old() {} -+fn new() {}' | sprout send-diff-message \ ++fn new() {}' | sprout messages send-diff \ --channel "$CHANNEL_ID" \ --diff - \ --repo "https://github.com/example/repo" \ --commit "abcdef1234567890abcdef1234567890abcdef12" | jq . -# send-diff-message with metadata -echo "diff content" | sprout send-diff-message \ +# messages send-diff with metadata +echo "diff content" | sprout messages send-diff \ --channel "$CHANNEL_ID" \ --diff - \ --repo "https://github.com/example/repo" \ @@ -276,8 +233,8 @@ echo "diff content" | sprout send-diff-message \ --lang "rust" \ --description "Refactored main" | jq . -# send-diff-message with branch + PR metadata -echo "diff content" | sprout send-diff-message \ +# messages send-diff with branch + PR metadata +echo "diff content" | sprout messages send-diff \ --channel "$CHANNEL_ID" \ --diff - \ --repo "https://github.com/example/repo" \ @@ -288,99 +245,92 @@ echo "diff content" | sprout send-diff-message \ --pr 42 | jq . ``` -### 6.6 Reactions +### 6.5 Reactions ```bash # Send a message to react to -REACT_MSG=$(sprout send-message --channel "$CHANNEL_ID" --content "React to this") +REACT_MSG=$(sprout messages send --channel "$CHANNEL_ID" --content "React to this") REACT_ID=$(echo "$REACT_MSG" | jq -r '.id // .event_id') -# add-reaction -sprout add-reaction --event "$REACT_ID" --emoji "👍" | jq . +# reactions add +sprout reactions add --event "$REACT_ID" --emoji "👍" | jq . -# get-reactions -sprout get-reactions --event "$REACT_ID" | jq . +# reactions get +sprout reactions get --event "$REACT_ID" | jq . -# remove-reaction -sprout remove-reaction --event "$REACT_ID" --emoji "👍" | jq . +# reactions remove +sprout reactions remove --event "$REACT_ID" --emoji "👍" | jq . ``` -### 6.7 DMs +### 6.6 DMs ```bash -# list-dms -sprout list-dms | jq . +# dms list +sprout dms list | jq . -# open-dm (needs a real pubkey — use your own or a test one) +# dms open (needs a real pubkey — use your own or a test one) # Get your own pubkey first: -MY_PUBKEY=$(sprout get-users | jq -r '.pubkey // .[0].pubkey // empty') +MY_PUBKEY=$(sprout users get | jq -r '.pubkey // .[0].pubkey // empty') echo "My pubkey: $MY_PUBKEY" -# open-dm with a synthetic pubkey (relay will create the user) -DM_RESULT=$(sprout open-dm --pubkey "0000000000000000000000000000000000000000000000000000000000000001") +# dms open with a synthetic pubkey (relay will create the user) +DM_RESULT=$(sprout dms open --pubkey "0000000000000000000000000000000000000000000000000000000000000001") echo "$DM_RESULT" | jq . DM_ID=$(echo "$DM_RESULT" | jq -r '.channel_id // .id // empty') -# add-dm-member (requires messages:write scope — NOT admin:channels) -sprout add-dm-member --channel "$DM_ID" \ +# dms add-member (requires messages:write scope — NOT admin:channels) +sprout dms add-member --channel "$DM_ID" \ --pubkey "0000000000000000000000000000000000000000000000000000000000000002" | jq . ``` -### 6.8 Users & Presence +### 6.7 Users & Presence ```bash -# get-users — own profile (0 pubkeys) -sprout get-users | jq . - -# get-users — single pubkey -sprout get-users --pubkey "$MY_PUBKEY" | jq . +# users get — own profile (0 pubkeys) +sprout users get | jq . -# get-users — batch (2+ pubkeys) -sprout get-users --pubkey "$MY_PUBKEY" --pubkey "$MY_PUBKEY" | jq . +# users get — single pubkey +sprout users get --pubkey "$MY_PUBKEY" | jq . -# set-profile -sprout set-profile --name "CLI Test Agent" --about "Testing sprout-cli" | jq . +# users get — batch (2+ pubkeys) +sprout users get --pubkey "$MY_PUBKEY" --pubkey "$MY_PUBKEY" | jq . -# get-presence -sprout get-presence --pubkeys "$MY_PUBKEY" | jq . +# users set-profile +sprout users set-profile --name "CLI Test Agent" --about "Testing sprout-cli" | jq . -# set-presence -sprout set-presence --status online | jq . -sprout set-presence --status away | jq . -sprout set-presence --status offline | jq . +# users presence +sprout users presence --pubkeys "$MY_PUBKEY" | jq . -# set-channel-add-policy -sprout set-channel-add-policy --policy anyone | jq . -sprout set-channel-add-policy --policy owner_only | jq . -sprout set-channel-add-policy --policy nobody | jq . -# Reset to default -sprout set-channel-add-policy --policy anyone | jq . +# users set-presence +sprout users set-presence --status online | jq . +sprout users set-presence --status away | jq . +sprout users set-presence --status offline | jq . ``` -### 6.9 Channel Members (add/remove require admin:channels) +### 6.8 Channel Members (add/remove require admin:channels) ```bash -# add-channel-member -sprout add-channel-member --channel "$CHANNEL_ID" \ +# channels add-member +sprout channels add-member --channel "$CHANNEL_ID" \ --pubkey "0000000000000000000000000000000000000000000000000000000000000001" \ --role member | jq . -# list-channel-members -sprout list-channel-members --channel "$CHANNEL_ID" | jq . +# channels members +sprout channels members --channel "$CHANNEL_ID" | jq . -# remove-channel-member -sprout remove-channel-member --channel "$CHANNEL_ID" \ +# channels remove-member +sprout channels remove-member --channel "$CHANNEL_ID" \ --pubkey "0000000000000000000000000000000000000000000000000000000000000001" | jq . ``` -### 6.10 Workflows +### 6.9 Workflows ```bash -# create-workflow +# workflows create # NOTE: trigger uses `on:` tag (serde internally tagged enum). # Valid triggers: message_posted, reaction_added, diff_posted, schedule, webhook # Steps use `action:` tag: send_message, send_dm, set_channel_topic, add_reaction, etc. -WF=$(sprout create-workflow --channel "$CHANNEL_ID" \ +WF=$(sprout workflows create --channel "$CHANNEL_ID" \ --yaml 'name: test-wf trigger: on: webhook @@ -391,14 +341,14 @@ steps: echo "$WF" WF_ID=$(echo "$WF" | jq -r '.id') -# list-workflows -sprout list-workflows --channel "$CHANNEL_ID" | jq . +# workflows list +sprout workflows list --channel "$CHANNEL_ID" | jq . -# get-workflow -sprout get-workflow --workflow "$WF_ID" | jq . +# workflows get +sprout workflows get --workflow "$WF_ID" | jq . -# update-workflow -sprout update-workflow --workflow "$WF_ID" \ +# workflows update +sprout workflows update --workflow "$WF_ID" \ --yaml 'name: test-wf-updated trigger: on: webhook @@ -407,43 +357,43 @@ steps: action: send_message text: "Updated"' | jq . -# trigger-workflow -sprout trigger-workflow --workflow "$WF_ID" | jq . +# workflows trigger +sprout workflows trigger --workflow "$WF_ID" | jq . -# get-workflow-runs -sprout get-workflow-runs --workflow "$WF_ID" | jq . +# workflows runs +sprout workflows runs --workflow "$WF_ID" | jq . -# approve-step — requires a workflow run waiting for approval +# workflows approve — requires a workflow run waiting for approval # This is hard to test ad-hoc without a workflow that has an approval gate. # Test the validation instead: -sprout approve-step --token "00000000-0000-0000-0000-000000000000" --approved true 2>&1 || true +sprout workflows approve --token "00000000-0000-0000-0000-000000000000" --approved 2>&1 || true # Should fail with relay error (token not found), not a validation error -# delete-workflow -sprout delete-workflow --workflow "$WF_ID" | jq . +# workflows delete +sprout workflows delete --workflow "$WF_ID" | jq . ``` -### 6.11 Feed +### 6.10 Feed ```bash -sprout get-feed | jq . -sprout get-feed --limit 5 | jq . +sprout feed get | jq . +sprout feed get --limit 5 | jq . ``` -### 6.12 Forum & Voting +### 6.11 Forum & Voting ```bash # Send a forum post (kind 45001) to the forum channel -FORUM_POST=$(sprout send-message --channel "$FORUM_ID" \ +FORUM_POST=$(sprout messages send --channel "$FORUM_ID" \ --content "Forum post for vote testing" --kind 45001 | jq .) echo "$FORUM_POST" FORUM_EVENT_ID=$(echo "$FORUM_POST" | jq -r '.id // .event_id') -# vote-on-post (up) -sprout vote-on-post --event "$FORUM_EVENT_ID" --direction up | jq . +# messages vote (up) +sprout messages vote --event "$FORUM_EVENT_ID" --direction up | jq . -# vote-on-post (down) -sprout vote-on-post --event "$FORUM_EVENT_ID" --direction down | jq . +# messages vote (down) +sprout messages vote --event "$FORUM_EVENT_ID" --direction down | jq . ``` --- @@ -454,43 +404,37 @@ Verify the CLI produces correct JSON on stderr and correct exit codes. ```bash # Exit 1: Invalid UUID -sprout get-channel --channel "not-a-uuid" 2>&1; echo "exit: $?" +sprout channels get --channel "not-a-uuid" 2>&1; echo "exit: $?" # stderr: {"error":"user_error","message":"invalid UUID: not-a-uuid"} # exit: 1 # Exit 1: Invalid hex64 -sprout delete-message --event "not-hex" 2>&1; echo "exit: $?" +sprout messages delete --event "not-hex" 2>&1; echo "exit: $?" # stderr: {"error":"user_error","message":"must be a 64-character hex string: not-hex"} # exit: 1 -# Exit 1: Invalid --approved value -sprout approve-step --token "00000000-0000-0000-0000-000000000000" \ - --approved maybe 2>&1; echo "exit: $?" -# stderr: {"error":"user_error","message":"--approved must be 'true' or 'false' (got: maybe)"} -# exit: 1 - # Exit 1: Invalid --type value -sprout create-channel --name x --type invalid --visibility open 2>&1; echo "exit: $?" +sprout channels create --name x --type invalid --visibility open 2>&1; echo "exit: $?" # stderr: {"error":"user_error","message":"--type must be 'stream' or 'forum' (got: invalid)"} # exit: 1 # Exit 1: Invalid --direction value -sprout vote-on-post --event "$(printf '0%.0s' {1..64})" \ +sprout messages vote --event "$(printf '0%.0s' {1..64})" \ --direction sideways 2>&1; echo "exit: $?" # exit: 1 # Exit 1: Empty body guard -sprout set-profile 2>&1; echo "exit: $?" +sprout users set-profile 2>&1; echo "exit: $?" # exit: 1 (at least one field required) # Exit 3: No auth configured -env -u SPROUT_API_TOKEN -u SPROUT_PRIVATE_KEY -u SPROUT_PUBKEY \ - cargo run -p sprout-cli -- list-channels 2>&1; echo "exit: $?" -# stderr: {"error":"auth_error","message":"auth error: Set SPROUT_API_TOKEN, SPROUT_PRIVATE_KEY, or SPROUT_PUBKEY"} +env -u SPROUT_PRIVATE_KEY -u SPROUT_PUBKEY \ + cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?" +# stderr: {"error":"auth_error","message":"auth error: Set SPROUT_PRIVATE_KEY or SPROUT_PUBKEY"} # exit: 3 # Exit 2: Non-existent channel (valid UUID) -sprout get-channel --channel "00000000-0000-0000-0000-000000000000" 2>&1; echo "exit: $?" +sprout channels get --channel "00000000-0000-0000-0000-000000000000" 2>&1; echo "exit: $?" # stderr: {"error":"relay_error","message":"..."} # exit: 2 ``` @@ -499,24 +443,20 @@ sprout get-channel --channel "00000000-0000-0000-0000-000000000000" 2>&1; echo " ## 8. Auth Mode Testing -Test all three authentication tiers. +Test both authentication modes. ```bash -# Mode 1: Bearer token (SPROUT_API_TOKEN) -SPROUT_API_TOKEN="sprout_tok_..." sprout list-channels | jq . +# Mode 1: Private key (SPROUT_PRIVATE_KEY) +SPROUT_PRIVATE_KEY="nsec1..." sprout channels list | jq . # Should succeed -# Mode 2: Private key auto-mint (SPROUT_PRIVATE_KEY) -SPROUT_PRIVATE_KEY="nsec1..." sprout list-channels | jq . -# Should succeed (mints a 1-day token at startup) - -# Mode 3: Dev mode (SPROUT_PUBKEY) — only works with SPROUT_REQUIRE_AUTH_TOKEN=false -SPROUT_PUBKEY="" sprout list-channels | jq . +# Mode 2: Dev mode (SPROUT_PUBKEY) — only works with SPROUT_REQUIRE_AUTH_TOKEN=false +SPROUT_PUBKEY="" sprout channels list | jq . # Should succeed # No auth → exit 3 -env -u SPROUT_API_TOKEN -u SPROUT_PRIVATE_KEY -u SPROUT_PUBKEY \ - cargo run -p sprout-cli -- list-channels 2>&1; echo "exit: $?" +env -u SPROUT_PRIVATE_KEY -u SPROUT_PUBKEY \ + cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?" # exit: 3 ``` @@ -526,8 +466,8 @@ env -u SPROUT_API_TOKEN -u SPROUT_PRIVATE_KEY -u SPROUT_PUBKEY \ ```bash # Delete test channels -sprout delete-channel --channel "$CHANNEL_ID" | jq . -sprout delete-channel --channel "$FORUM_ID" | jq . +sprout channels delete --channel "$CHANNEL_ID" | jq . +sprout channels delete --channel "$FORUM_ID" | jq . ``` --- @@ -536,51 +476,57 @@ sprout delete-channel --channel "$FORUM_ID" | jq . | # | Command | Tested | Notes | |---|---------|:------:|-------| -| 1 | `send-message` | ☐ | Basic, reply, broadcast, mentions | -| 2 | `send-diff-message` | ☐ | Stdin, metadata, branch/PR | -| 3 | `edit-message` | ☐ | | -| 4 | `delete-message` | ☐ | | -| 5 | `get-messages` | ☐ | With limit | -| 6 | `get-thread` | ☐ | | -| 7 | `search` | ☐ | With limit | -| 8 | `list-channels` | ☐ | With visibility, member | -| 9 | `get-channel` | ☐ | | -| 10 | `create-channel` | ☐ | Stream and forum | -| 11 | `update-channel` | ☐ | | -| 12 | `set-channel-topic` | ☐ | | -| 13 | `set-channel-purpose` | ☐ | | -| 14 | `join-channel` | ☐ | | -| 15 | `leave-channel` | ☐ | | -| 16 | `archive-channel` | ☐ | Needs admin:channels | -| 17 | `unarchive-channel` | ☐ | Needs admin:channels | -| 18 | `delete-channel` | ☐ | Needs admin:channels | -| 19 | `list-channel-members` | ☐ | | -| 20 | `add-channel-member` | ☐ | Needs admin:channels | -| 21 | `remove-channel-member` | ☐ | Needs admin:channels | -| 22 | `get-canvas` | ☐ | | -| 23 | `set-canvas` | ☐ | Direct and stdin | -| 24 | `add-reaction` | ☐ | | -| 25 | `remove-reaction` | ☐ | | -| 26 | `get-reactions` | ☐ | | -| 27 | `list-dms` | ☐ | | -| 28 | `open-dm` | ☐ | | -| 29 | `add-dm-member` | ☐ | Needs messages:write | -| 30 | `get-users` | ☐ | Self, single, batch | -| 31 | `set-profile` | ☐ | | -| 32 | `get-presence` | ☐ | | -| 33 | `set-presence` | ☐ | online, away, offline | -| 34 | `set-channel-add-policy` | ☐ | anyone, owner_only, nobody | -| 35 | `list-workflows` | ☐ | | -| 36 | `create-workflow` | ☐ | | -| 37 | `update-workflow` | ☐ | | -| 38 | `delete-workflow` | ☐ | | -| 39 | `trigger-workflow` | ☐ | | -| 40 | `get-workflow-runs` | ☐ | | -| 41 | `get-workflow` | ☐ | | -| 42 | `approve-step` | ☐ | Validation only (needs approval gate) | -| 43 | `get-feed` | ☐ | | -| 44 | `vote-on-post` | ☐ | Up and down | -| 45 | `auth` | ☐ | Mint token via NIP-98 | -| 46 | `list-tokens` | ☐ | | -| 47 | `delete-token` | ☐ | | -| 48 | `delete-all-tokens` | ☐ | Optional (destructive) | +| 1 | `messages send` | ☐ | Basic, reply, broadcast, mentions | +| 2 | `messages send-diff` | ☐ | Stdin, metadata, branch/PR | +| 3 | `messages edit` | ☐ | | +| 4 | `messages delete` | ☐ | | +| 5 | `messages get` | ☐ | With limit | +| 6 | `messages thread` | ☐ | | +| 7 | `messages search` | ☐ | With limit | +| 8 | `messages vote` | ☐ | Up and down | +| 9 | `channels list` | ☐ | With visibility, member | +| 10 | `channels get` | ☐ | | +| 11 | `channels create` | ☐ | Stream and forum | +| 12 | `channels update` | ☐ | | +| 13 | `channels topic` | ☐ | | +| 14 | `channels purpose` | ☐ | | +| 15 | `channels join` | ☐ | | +| 16 | `channels leave` | ☐ | | +| 17 | `channels archive` | ☐ | Needs admin:channels | +| 18 | `channels unarchive` | ☐ | Needs admin:channels | +| 19 | `channels delete` | ☐ | Needs admin:channels | +| 20 | `channels members` | ☐ | | +| 21 | `channels add-member` | ☐ | Needs admin:channels | +| 22 | `channels remove-member` | ☐ | Needs admin:channels | +| 23 | `canvas get` | ☐ | | +| 24 | `canvas set` | ☐ | Direct and stdin | +| 25 | `reactions add` | ☐ | | +| 26 | `reactions remove` | ☐ | | +| 27 | `reactions get` | ☐ | | +| 28 | `dms list` | ☐ | | +| 29 | `dms open` | ☐ | | +| 30 | `dms add-member` | ☐ | Needs messages:write | +| 31 | `users get` | ☐ | Self, single, batch | +| 32 | `users set-profile` | ☐ | | +| 33 | `users presence` | ☐ | | +| 34 | `users set-presence` | ☐ | online, away, offline | +| 35 | `workflows list` | ☐ | | +| 36 | `workflows create` | ☐ | | +| 37 | `workflows update` | ☐ | | +| 38 | `workflows delete` | ☐ | | +| 39 | `workflows trigger` | ☐ | | +| 40 | `workflows runs` | ☐ | | +| 41 | `workflows get` | ☐ | | +| 42 | `workflows approve` | ☐ | Validation only (needs approval gate); use --approved / --no-approved | +| 43 | `feed get` | ☐ | | +| 44 | `social publish-note` | ☐ | | +| 45 | `social set-contact-list` | ☐ | | +| 46 | `social get-event` | ☐ | | +| 47 | `social get-user-notes` | ☐ | | +| 48 | `social get-contact-list` | ☐ | | +| 49 | `repos create` | ☐ | | +| 50 | `repos get` | ☐ | | +| 51 | `repos list` | ☐ | | +| 52 | `upload file` | ☐ | | +| 53 | `pack validate` | ☐ | Local, no relay | +| 54 | `pack inspect` | ☐ | Local, no relay | diff --git a/crates/sprout-cli/src/commands/channels.rs b/crates/sprout-cli/src/commands/channels.rs index f7c08bb36..f809ef913 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(()) @@ -150,7 +135,7 @@ pub async fn cmd_update_channel( let builder = sprout_sdk::build_update_channel(channel_uuid, name, description) .map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -167,7 +152,7 @@ pub async fn cmd_set_channel_topic( let builder = sprout_sdk::build_set_topic(channel_uuid, topic) .map_err(|e| CliError::Other(format!("build_set_topic failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -184,7 +169,7 @@ pub async fn cmd_set_channel_purpose( let builder = sprout_sdk::build_set_purpose(channel_uuid, purpose) .map_err(|e| CliError::Other(format!("build_set_purpose failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -197,7 +182,7 @@ pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result let builder = sprout_sdk::build_join(channel_uuid) .map_err(|e| CliError::Other(format!("build_join failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -210,7 +195,7 @@ pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Resul let builder = sprout_sdk::build_leave(channel_uuid) .map_err(|e| CliError::Other(format!("build_leave failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -223,7 +208,7 @@ pub async fn cmd_archive_channel(client: &SproutClient, channel_id: &str) -> Res let builder = sprout_sdk::build_archive(channel_uuid) .map_err(|e| CliError::Other(format!("build_archive failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -239,7 +224,7 @@ pub async fn cmd_unarchive_channel( let builder = sprout_sdk::build_unarchive(channel_uuid) .map_err(|e| CliError::Other(format!("build_unarchive failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -252,7 +237,7 @@ pub async fn cmd_delete_channel(client: &SproutClient, channel_id: &str) -> Resu let builder = sprout_sdk::build_delete_channel(channel_uuid) .map_err(|e| CliError::Other(format!("build_delete_channel failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -284,7 +269,7 @@ pub async fn cmd_add_channel_member( let builder = sprout_sdk::build_add_member(channel_uuid, pubkey, typed_role) .map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -302,7 +287,7 @@ pub async fn cmd_remove_channel_member( let builder = sprout_sdk::build_remove_member(channel_uuid, pubkey) .map_err(|e| CliError::Other(format!("build_remove_member failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -320,7 +305,7 @@ pub async fn cmd_set_canvas( let builder = sprout_sdk::build_set_canvas(channel_uuid, &content) .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) diff --git a/crates/sprout-cli/src/commands/messages.rs b/crates/sprout-cli/src/commands/messages.rs index 7b7e36f87..5e386a2d2 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: diff --git a/crates/sprout-cli/src/commands/social.rs b/crates/sprout-cli/src/commands/social.rs index efc3b68ce..dc9a14364 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)] diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs index 4cf2b18ae..4e7ce109b 100644 --- a/crates/sprout-cli/src/lib.rs +++ b/crates/sprout-cli/src/lib.rs @@ -52,6 +52,7 @@ where #[derive(Parser)] #[command(name = "sprout", about = "Sprout CLI — interact with a Sprout relay")] struct Cli { + /// Relay URL (http:// or https://). Overrides SPROUT_RELAY_URL env var. #[arg( long, env = "SPROUT_RELAY_URL", @@ -72,14 +73,60 @@ struct Cli { } // --------------------------------------------------------------------------- -// Subcommands +// Subcommand groups // --------------------------------------------------------------------------- #[derive(Subcommand)] enum Cmd { - // ---- Messages ---------------------------------------------------------- + /// Send, read, search, and manage messages + #[command(subcommand)] + Messages(MessagesCmd), + /// Create, configure, and manage channels + #[command(subcommand)] + Channels(ChannelsCmd), + /// Get and set channel canvas documents + #[command(subcommand)] + Canvas(CanvasCmd), + /// Add, remove, and list emoji reactions + #[command(subcommand)] + Reactions(ReactionsCmd), + /// List, open, and manage direct messages + #[command(subcommand)] + Dms(DmsCmd), + /// Look up users and manage profiles and presence + #[command(subcommand)] + Users(UsersCmd), + /// Create, trigger, and manage workflows + #[command(subcommand)] + Workflows(WorkflowsCmd), + /// Read the activity feed + #[command(subcommand)] + Feed(FeedCmd), + /// Publish notes and manage the social graph (NIP-01/02) + #[command(subcommand)] + Social(SocialCmd), + /// Announce and discover git repositories (NIP-34) + #[command(subcommand)] + Repos(ReposCmd), + /// Upload files to the relay's Blossom store + #[command(subcommand)] + Upload(UploadCmd), + /// Persona pack operations (local, no relay connection needed) + #[command(subcommand)] + Pack(PackCmd), +} + +// --------------------------------------------------------------------------- +// Messages subcommands +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +pub enum MessagesCmd { /// Send a message to a channel - SendMessage { + #[command( + after_help = "Examples:\n sprout messages send --channel --content \"hello\"\n sprout messages send --channel --content \"@alice check this\" --mention alice" + )] + Send { #[arg(long)] channel: String, #[arg(long)] @@ -96,8 +143,8 @@ enum Cmd { #[arg(long = "file")] files: Vec, }, - /// Send a diff/code-review message - SendDiffMessage { + /// Send a code diff / patch to a channel + SendDiff { #[arg(long)] channel: String, #[arg(long)] @@ -123,53 +170,60 @@ enum Cmd { #[arg(long)] reply_to: Option, }, + /// Edit a previously sent message + Edit { + /// Event ID of the message to edit (64-char hex) + #[arg(long)] + event: String, + /// New message content + #[arg(long)] + content: String, + }, /// Delete a message by event ID - DeleteMessage { + Delete { #[arg(long)] event: String, }, - /// Get messages from a channel - GetMessages { + /// Retrieve messages from a channel + #[command( + after_help = "Examples:\n sprout messages get --channel \n sprout messages get --channel --limit 50 --kinds 1,1984" + )] + Get { #[arg(long)] channel: String, + /// Maximum number of results to return #[arg(long)] limit: Option, #[arg(long)] before: Option, #[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 { #[arg(long)] channel: String, #[arg(long)] event: String, #[arg(long)] depth_limit: Option, + /// Maximum number of results to return #[arg(long)] limit: Option, }, - /// Search messages + /// Full-text search across messages Search { #[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,29 +231,36 @@ enum Cmd { #[arg(long)] direction: String, }, +} - /// Upload a file to the relay (returns BlobDescriptor JSON) - UploadFile { - /// Path to the file to upload - #[arg(long)] - file: String, - }, +// --------------------------------------------------------------------------- +// Channels subcommands +// --------------------------------------------------------------------------- - // ---- Channels ---------------------------------------------------------- - /// List channels - ListChannels { +#[derive(Subcommand)] +pub enum ChannelsCmd { + /// List channels visible to the current identity + #[command( + after_help = "Examples:\n sprout channels list\n sprout channels list --visibility open" + )] + List { + /// Filter by visibility (e.g. open, closed) #[arg(long)] visibility: Option, + /// Only show channels where the current identity is a member #[arg(long, default_value_t = false)] member: bool, }, - /// Get a channel by ID - GetChannel { + /// Get details for a single channel + Get { #[arg(long)] channel: String, }, /// Create a new channel - CreateChannel { + #[command( + after_help = "Examples:\n sprout channels create --name general --type stream --visibility open\n sprout channels create --name design --type forum --visibility open --description \"Design discussions\"" + )] + Create { #[arg(long)] name: String, #[arg(long = "type")] @@ -209,8 +270,8 @@ enum Cmd { #[arg(long)] description: Option, }, - /// Update a channel's name or description - UpdateChannel { + /// Update channel name or description + Update { #[arg(long)] channel: String, #[arg(long)] @@ -218,52 +279,53 @@ enum Cmd { #[arg(long)] description: Option, }, - /// Set a channel's topic - SetChannelTopic { + /// Set the channel topic + Topic { #[arg(long)] channel: String, #[arg(long)] topic: String, }, - /// Set a channel's purpose - SetChannelPurpose { + /// Set the channel purpose + Purpose { #[arg(long)] channel: String, #[arg(long)] purpose: String, }, /// Join a channel - JoinChannel { + Join { #[arg(long)] channel: String, }, /// Leave a channel - LeaveChannel { + Leave { #[arg(long)] channel: String, }, /// Archive a channel - ArchiveChannel { + Archive { #[arg(long)] channel: String, }, /// Unarchive a channel - UnarchiveChannel { + Unarchive { #[arg(long)] channel: String, }, - /// Delete a channel - DeleteChannel { + /// Delete a channel permanently + Delete { #[arg(long)] channel: String, }, - /// List channel members - ListChannelMembers { + /// List members of a channel + Members { #[arg(long)] channel: String, }, /// Add a member to a channel - AddChannelMember { + #[command(name = "add-member")] + AddMember { #[arg(long)] channel: String, #[arg(long)] @@ -272,75 +334,104 @@ enum Cmd { role: Option, }, /// Remove a member from a channel - RemoveChannelMember { + #[command(name = "remove-member")] + RemoveMember { #[arg(long)] channel: String, #[arg(long)] pubkey: String, }, - /// Get a channel's canvas - GetCanvas { +} + +// --------------------------------------------------------------------------- +// Canvas subcommands +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +pub enum CanvasCmd { + /// Get the canvas document for a channel + Get { #[arg(long)] channel: String, }, - /// Set a channel's canvas content - SetCanvas { + /// Set (replace) the canvas document for a channel + Set { #[arg(long)] channel: String, #[arg(long)] content: String, }, +} - // ---- Reactions --------------------------------------------------------- - /// Add a reaction to a message - AddReaction { +// --------------------------------------------------------------------------- +// Reactions subcommands +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +pub enum ReactionsCmd { + /// Add an emoji reaction to a message + Add { #[arg(long)] event: String, #[arg(long)] emoji: String, }, - /// Remove a reaction from a message - RemoveReaction { + /// Remove an emoji reaction from a message + Remove { #[arg(long)] event: String, #[arg(long)] emoji: String, }, - /// Get reactions on a message - GetReactions { + /// List reactions on a message + Get { #[arg(long)] event: String, }, +} + +// --------------------------------------------------------------------------- +// DMs subcommands +// --------------------------------------------------------------------------- - // ---- DMs --------------------------------------------------------------- - /// List DM conversations - ListDms { +#[derive(Subcommand)] +pub enum DmsCmd { + /// List direct message conversations + List { + /// Maximum number of results to return #[arg(long)] limit: Option, }, - /// Open a DM with one or more users (1–8 pubkeys) - OpenDm { + /// Open a new direct message with one or more users + Open { #[arg(long = "pubkey")] pubkeys: Vec, }, - /// Add a member to a DM group - AddDmMember { + /// Add a member to an existing DM conversation + AddMember { #[arg(long)] channel: String, #[arg(long)] pubkey: String, }, +} + +// --------------------------------------------------------------------------- +// Users subcommands +// --------------------------------------------------------------------------- - // ---- Users ------------------------------------------------------------- - /// Get user profiles by pubkey or display name - GetUsers { +#[derive(Subcommand)] +pub enum UsersCmd { + /// Look up user profiles by pubkey or name + Get { #[arg(long = "pubkey")] pubkeys: Vec, /// 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 { #[arg(long)] name: Option, @@ -351,32 +442,44 @@ enum Cmd { #[arg(long)] nip05: Option, }, - /// Get presence status for users (comma-separated pubkeys) - GetPresence { + /// Get presence status for users + Presence { #[arg(long)] pubkeys: String, }, - /// Set your presence status + /// Set your presence status (online/away/offline) + #[command(name = "set-presence")] SetPresence { #[arg(long)] status: String, }, +} + +// --------------------------------------------------------------------------- +// Workflows subcommands +// --------------------------------------------------------------------------- - // ---- Workflows --------------------------------------------------------- +#[derive(Subcommand)] +pub enum WorkflowsCmd { /// List workflows in a channel - ListWorkflows { + List { #[arg(long)] channel: String, }, - /// Create a workflow in a channel - CreateWorkflow { + /// Get details for a single workflow + Get { + #[arg(long)] + workflow: String, + }, + /// Create a workflow from a YAML definition + Create { #[arg(long)] channel: String, #[arg(long)] yaml: String, }, - /// Update a workflow - UpdateWorkflow { + /// Update a workflow's YAML definition + Update { #[arg(long)] channel: String, #[arg(long)] @@ -385,52 +488,66 @@ enum Cmd { yaml: String, }, /// Delete a workflow - DeleteWorkflow { + Delete { #[arg(long)] workflow: String, }, - /// Trigger a workflow manually - TriggerWorkflow { + /// Trigger a workflow run + #[command(after_help = "Examples:\n sprout workflows trigger --workflow ")] + Trigger { #[arg(long)] workflow: String, }, - /// Get workflow run history - GetWorkflowRuns { + /// List runs for a workflow + Runs { #[arg(long)] workflow: String, + /// Maximum number of results to return #[arg(long)] limit: Option, }, - /// 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 --no-approved --note \"needs revision\"" + )] + Approve { /// The approval token UUID (from the approval request) #[arg(long)] token: String, - /// Whether to approve: "true" or "false" - #[arg(long)] - approved: String, + /// Approve the step (pass --no-approved to deny) + #[arg(long, default_value_t = true)] + approved: bool, + /// Optional note to include with the approval/denial #[arg(long)] note: Option, }, +} - // ---- Feed -------------------------------------------------------------- - /// Get your activity feed - GetFeed { +// --------------------------------------------------------------------------- +// Feed subcommands +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +pub enum FeedCmd { + /// Get recent activity feed entries + Get { #[arg(long)] since: Option, + /// Maximum number of results to return #[arg(long)] limit: Option, #[arg(long)] types: Option, }, +} + +// --------------------------------------------------------------------------- +// Social subcommands +// --------------------------------------------------------------------------- - // Social - /// Publish a short text note (kind:1) to the global feed. +#[derive(Subcommand)] +pub enum SocialCmd { + /// Publish a text note (NIP-01 kind:1) #[command(name = "publish-note")] PublishNote { /// Text content of the note. @@ -440,24 +557,21 @@ enum Cmd { #[arg(long)] reply_to: Option, }, - - /// Set the authenticated user's contact/follow list (kind:3). Replaces the entire list. + /// Set your contact list (NIP-02 kind:3) #[command(name = "set-contact-list")] SetContactList { /// JSON array of contacts: [{"pubkey":"hex","relay_url":"...","petname":"..."}] #[arg(long)] contacts: String, }, - - /// Get a single event by event ID (notes, profiles, contacts, articles, channel events). + /// Get a single event by ID #[command(name = "get-event")] GetEvent { /// 64-char hex event ID. #[arg(long)] event: String, }, - - /// List kind:1 text notes by a specific user. + /// Get recent notes published by a user #[command(name = "get-user-notes")] GetUserNotes { /// 64-char hex pubkey of the author. @@ -470,19 +584,23 @@ enum Cmd { #[arg(long)] before: Option, }, - - /// Get a user's contact/follow list (kind:3) by hex pubkey. + /// Get a user's contact list #[command(name = "get-contact-list")] GetContactList { /// 64-char hex pubkey. #[arg(long)] pubkey: String, }, +} + +// --------------------------------------------------------------------------- +// Repos subcommands +// --------------------------------------------------------------------------- - // ---- Git Repos --------------------------------------------------------- - /// Create a git repository (publishes kind:30617 announcement) - #[command(name = "create-repo")] - CreateRepo { +#[derive(Subcommand)] +pub enum ReposCmd { + /// Announce a git repository (NIP-34) + Create { /// Repository identifier: [a-zA-Z0-9._-]{1,64} #[arg(long)] id: String, @@ -502,9 +620,8 @@ enum Cmd { #[arg(long = "relay")] relays: Vec, }, - /// Get a repository's announcement event by ID - #[command(name = "get-repo")] - GetRepo { + /// Get a repository announcement + Get { /// Repository identifier (d-tag) #[arg(long)] id: String, @@ -512,9 +629,8 @@ enum Cmd { #[arg(long)] owner: Option, }, - /// 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,13 +638,26 @@ 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 { @@ -545,20 +674,9 @@ enum PackCmd { } // --------------------------------------------------------------------------- -// Internal helpers +// Command dispatch // --------------------------------------------------------------------------- -/// Parse a string flag that must be "true" or "false". -fn parse_bool_flag(flag_name: &str, value: &str) -> Result { - match value { - "true" => Ok(true), - "false" => Ok(false), - other => Err(CliError::Usage(format!( - "{flag_name} must be 'true' or 'false' (got: {other})" - ))), - } -} - async fn run(cli: Cli) -> Result<(), CliError> { let relay_url = client::normalize_relay_url(&cli.relay); @@ -597,308 +715,340 @@ async fn run(cli: Cli) -> Result<(), CliError> { let client = SproutClient::new(relay_url, keys, auth_tag, auth_tag_json)?; match cli.command { - // ---- Messages ------------------------------------------------------ - Cmd::SendMessage { - channel, - content, - kind, - reply_to, - broadcast, - mentions, - files, - } => { - commands::messages::cmd_send_message( - &client, - commands::messages::SendMessageParams { - channel_id: channel, - content, - kind, - reply_to, - broadcast, - mentions, - files, - }, - ) - .await - } - Cmd::SendDiffMessage { - channel, - diff, - repo, - commit, - file, - parent_commit, - source_branch, - target_branch, - pr, - lang, - description, - reply_to, - } => { - commands::messages::cmd_send_diff_message( - &client, - commands::messages::SendDiffParams { - channel_id: channel, - diff, - repo_url: repo, - commit_sha: commit, - file_path: file, - parent_commit_sha: parent_commit, - source_branch, - target_branch, - pr_number: pr, - language: lang, - description, - reply_to, - }, - ) - .await - } - Cmd::DeleteMessage { event } => { - commands::messages::cmd_delete_message(&client, &event).await - } - Cmd::GetMessages { - channel, - limit, - before, - since, - kinds, - } => { - commands::messages::cmd_get_messages( - &client, - &channel, + Cmd::Messages(sub) => match sub { + MessagesCmd::Send { + channel, + content, + kind, + reply_to, + broadcast, + mentions, + files, + } => { + commands::messages::cmd_send_message( + &client, + commands::messages::SendMessageParams { + channel_id: channel, + content, + kind, + reply_to, + broadcast, + mentions, + files, + }, + ) + .await + } + MessagesCmd::SendDiff { + channel, + diff, + repo, + commit, + file, + parent_commit, + source_branch, + target_branch, + pr, + lang, + description, + reply_to, + } => { + commands::messages::cmd_send_diff_message( + &client, + commands::messages::SendDiffParams { + channel_id: channel, + diff, + repo_url: repo, + commit_sha: commit, + file_path: file, + parent_commit_sha: parent_commit, + source_branch, + target_branch, + pr_number: pr, + language: lang, + description, + reply_to, + }, + ) + .await + } + MessagesCmd::Edit { event, content } => { + commands::messages::cmd_edit_message(&client, &event, &content).await + } + MessagesCmd::Delete { event } => { + commands::messages::cmd_delete_message(&client, &event).await + } + MessagesCmd::Get { + channel, limit, before, since, - kinds.as_deref(), - ) - .await - } - Cmd::GetThread { - channel, - event, - depth_limit, - limit, - } => { - commands::messages::cmd_get_thread(&client, &channel, &event, depth_limit, limit).await - } - Cmd::Search { query, limit } => { - commands::messages::cmd_search(&client, &query, limit).await - } - Cmd::EditMessage { event, content } => { - commands::messages::cmd_edit_message(&client, &event, &content).await - } - Cmd::VoteOnPost { event, direction } => { - commands::messages::cmd_vote_on_post(&client, &event, &direction).await - } - Cmd::UploadFile { file } => { - let desc = client.upload_file(&file).await?; - println!( - "{}", - serde_json::to_string_pretty(&desc).map_err(|e| CliError::Other(e.to_string()))? - ); - Ok(()) - } + kinds, + } => { + commands::messages::cmd_get_messages( + &client, + &channel, + limit, + before, + since, + kinds.as_deref(), + ) + .await + } + MessagesCmd::Thread { + channel, + event, + depth_limit, + limit, + } => { + commands::messages::cmd_get_thread(&client, &channel, &event, depth_limit, limit) + .await + } + MessagesCmd::Search { query, limit } => { + commands::messages::cmd_search(&client, &query, limit).await + } + MessagesCmd::Vote { event, direction } => { + commands::messages::cmd_vote_on_post(&client, &event, &direction).await + } + }, - // ---- Channels ------------------------------------------------------ - Cmd::ListChannels { visibility, member } => { - commands::channels::cmd_list_channels(&client, visibility.as_deref(), Some(member)) + Cmd::Channels(sub) => match sub { + ChannelsCmd::List { visibility, member } => { + commands::channels::cmd_list_channels(&client, visibility.as_deref(), Some(member)) + .await + } + ChannelsCmd::Get { channel } => { + commands::channels::cmd_get_channel(&client, &channel).await + } + ChannelsCmd::Create { + name, + channel_type, + visibility, + description, + } => { + commands::channels::cmd_create_channel( + &client, + &name, + &channel_type, + &visibility, + description.as_deref(), + ) .await - } - Cmd::GetChannel { channel } => commands::channels::cmd_get_channel(&client, &channel).await, - Cmd::CreateChannel { - name, - channel_type, - visibility, - description, - } => { - commands::channels::cmd_create_channel( - &client, - &name, - &channel_type, - &visibility, - description.as_deref(), - ) - .await - } - Cmd::UpdateChannel { - channel, - name, - description, - } => { - commands::channels::cmd_update_channel( - &client, - &channel, - name.as_deref(), - description.as_deref(), - ) - .await - } - Cmd::SetChannelTopic { channel, topic } => { - commands::channels::cmd_set_channel_topic(&client, &channel, &topic).await - } - Cmd::SetChannelPurpose { channel, purpose } => { - commands::channels::cmd_set_channel_purpose(&client, &channel, &purpose).await - } - Cmd::JoinChannel { channel } => { - commands::channels::cmd_join_channel(&client, &channel).await - } - Cmd::LeaveChannel { channel } => { - commands::channels::cmd_leave_channel(&client, &channel).await - } - Cmd::ArchiveChannel { channel } => { - commands::channels::cmd_archive_channel(&client, &channel).await - } - Cmd::UnarchiveChannel { channel } => { - commands::channels::cmd_unarchive_channel(&client, &channel).await - } - Cmd::DeleteChannel { channel } => { - commands::channels::cmd_delete_channel(&client, &channel).await - } - Cmd::ListChannelMembers { channel } => { - commands::channels::cmd_list_channel_members(&client, &channel).await - } - Cmd::AddChannelMember { - channel, - pubkey, - role, - } => { - commands::channels::cmd_add_channel_member(&client, &channel, &pubkey, role.as_deref()) + } + ChannelsCmd::Update { + channel, + name, + description, + } => { + commands::channels::cmd_update_channel( + &client, + &channel, + name.as_deref(), + description.as_deref(), + ) .await - } - Cmd::RemoveChannelMember { channel, pubkey } => { - commands::channels::cmd_remove_channel_member(&client, &channel, &pubkey).await - } - Cmd::GetCanvas { channel } => commands::channels::cmd_get_canvas(&client, &channel).await, - Cmd::SetCanvas { channel, content } => { - commands::channels::cmd_set_canvas(&client, &channel, &content).await - } + } + ChannelsCmd::Topic { channel, topic } => { + commands::channels::cmd_set_channel_topic(&client, &channel, &topic).await + } + ChannelsCmd::Purpose { channel, purpose } => { + commands::channels::cmd_set_channel_purpose(&client, &channel, &purpose).await + } + ChannelsCmd::Join { channel } => { + commands::channels::cmd_join_channel(&client, &channel).await + } + ChannelsCmd::Leave { channel } => { + commands::channels::cmd_leave_channel(&client, &channel).await + } + ChannelsCmd::Archive { channel } => { + commands::channels::cmd_archive_channel(&client, &channel).await + } + ChannelsCmd::Unarchive { channel } => { + commands::channels::cmd_unarchive_channel(&client, &channel).await + } + ChannelsCmd::Delete { channel } => { + commands::channels::cmd_delete_channel(&client, &channel).await + } + ChannelsCmd::Members { channel } => { + commands::channels::cmd_list_channel_members(&client, &channel).await + } + ChannelsCmd::AddMember { + channel, + pubkey, + role, + } => { + commands::channels::cmd_add_channel_member( + &client, + &channel, + &pubkey, + role.as_deref(), + ) + .await + } + ChannelsCmd::RemoveMember { channel, pubkey } => { + commands::channels::cmd_remove_channel_member(&client, &channel, &pubkey).await + } + }, - // ---- Reactions ----------------------------------------------------- - Cmd::AddReaction { event, emoji } => { - commands::reactions::cmd_add_reaction(&client, &event, &emoji).await - } - Cmd::RemoveReaction { event, emoji } => { - commands::reactions::cmd_remove_reaction(&client, &event, &emoji).await - } - Cmd::GetReactions { event } => { - commands::reactions::cmd_get_reactions(&client, &event).await - } + Cmd::Canvas(sub) => match sub { + CanvasCmd::Get { channel } => { + commands::channels::cmd_get_canvas(&client, &channel).await + } + CanvasCmd::Set { channel, content } => { + commands::channels::cmd_set_canvas(&client, &channel, &content).await + } + }, - // ---- DMs ----------------------------------------------------------- - Cmd::ListDms { limit } => commands::dms::cmd_list_dms(&client, limit).await, - Cmd::OpenDm { pubkeys } => commands::dms::cmd_open_dm(&client, &pubkeys).await, - Cmd::AddDmMember { channel, pubkey } => { - commands::dms::cmd_add_dm_member(&client, &channel, &pubkey).await - } + Cmd::Reactions(sub) => match sub { + ReactionsCmd::Add { event, emoji } => { + commands::reactions::cmd_add_reaction(&client, &event, &emoji).await + } + ReactionsCmd::Remove { event, emoji } => { + commands::reactions::cmd_remove_reaction(&client, &event, &emoji).await + } + ReactionsCmd::Get { event } => { + commands::reactions::cmd_get_reactions(&client, &event).await + } + }, - // ---- Users --------------------------------------------------------- - Cmd::GetUsers { pubkeys, name } => { - commands::users::cmd_get_users(&client, &pubkeys, name.as_deref()).await - } - Cmd::SetProfile { - name, - avatar, - about, - nip05, - } => { - commands::users::cmd_set_profile( - &client, - name.as_deref(), - avatar.as_deref(), - about.as_deref(), - nip05.as_deref(), - ) - .await - } - Cmd::GetPresence { pubkeys } => commands::users::cmd_get_presence(&client, &pubkeys).await, - Cmd::SetPresence { status } => commands::users::cmd_set_presence(&client, &status).await, + Cmd::Dms(sub) => match sub { + DmsCmd::List { limit } => commands::dms::cmd_list_dms(&client, limit).await, + DmsCmd::Open { pubkeys } => commands::dms::cmd_open_dm(&client, &pubkeys).await, + DmsCmd::AddMember { channel, pubkey } => { + commands::dms::cmd_add_dm_member(&client, &channel, &pubkey).await + } + }, - // ---- Workflows ----------------------------------------------------- - Cmd::ListWorkflows { channel } => { - commands::workflows::cmd_list_workflows(&client, &channel).await - } - Cmd::CreateWorkflow { channel, yaml } => { - commands::workflows::cmd_create_workflow(&client, &channel, &yaml).await - } - Cmd::UpdateWorkflow { - channel, - workflow, - yaml, - } => commands::workflows::cmd_update_workflow(&client, &channel, &workflow, &yaml).await, - Cmd::DeleteWorkflow { workflow } => { - commands::workflows::cmd_delete_workflow(&client, &workflow).await - } - Cmd::TriggerWorkflow { workflow } => { - commands::workflows::cmd_trigger_workflow(&client, &workflow).await - } - Cmd::GetWorkflowRuns { workflow, limit } => { - commands::workflows::cmd_get_workflow_runs(&client, &workflow, limit).await - } - Cmd::GetWorkflow { workflow } => { - commands::workflows::cmd_get_workflow(&client, &workflow).await - } - Cmd::ApproveStep { - token, - approved, - note, - } => { - let approved = parse_bool_flag("--approved", &approved)?; - commands::workflows::cmd_approve_step(&client, &token, approved, note.as_deref()).await - } + Cmd::Users(sub) => match sub { + UsersCmd::Get { pubkeys, name } => { + commands::users::cmd_get_users(&client, &pubkeys, name.as_deref()).await + } + UsersCmd::SetProfile { + name, + avatar, + about, + nip05, + } => { + commands::users::cmd_set_profile( + &client, + name.as_deref(), + avatar.as_deref(), + about.as_deref(), + nip05.as_deref(), + ) + .await + } + UsersCmd::Presence { pubkeys } => { + commands::users::cmd_get_presence(&client, &pubkeys).await + } + UsersCmd::SetPresence { status } => { + commands::users::cmd_set_presence(&client, &status).await + } + }, - // ---- Feed ---------------------------------------------------------- - Cmd::GetFeed { - since, - limit, - types, - } => commands::feed::cmd_get_feed(&client, since, limit, types.as_deref()).await, + Cmd::Workflows(sub) => match sub { + WorkflowsCmd::List { channel } => { + commands::workflows::cmd_list_workflows(&client, &channel).await + } + WorkflowsCmd::Get { workflow } => { + commands::workflows::cmd_get_workflow(&client, &workflow).await + } + WorkflowsCmd::Create { channel, yaml } => { + commands::workflows::cmd_create_workflow(&client, &channel, &yaml).await + } + WorkflowsCmd::Update { + channel, + workflow, + yaml, + } => { + commands::workflows::cmd_update_workflow(&client, &channel, &workflow, &yaml).await + } + WorkflowsCmd::Delete { workflow } => { + commands::workflows::cmd_delete_workflow(&client, &workflow).await + } + WorkflowsCmd::Trigger { workflow } => { + commands::workflows::cmd_trigger_workflow(&client, &workflow).await + } + WorkflowsCmd::Runs { workflow, limit } => { + commands::workflows::cmd_get_workflow_runs(&client, &workflow, limit).await + } + WorkflowsCmd::Approve { + token, + approved, + note, + } => { + // approved is already a bool — no parse_bool_flag needed + commands::workflows::cmd_approve_step(&client, &token, approved, note.as_deref()) + .await + } + }, - // ---- Social -------------------------------------------------------- - Cmd::PublishNote { content, reply_to } => { - commands::social::cmd_publish_note(&client, &content, reply_to.as_deref()).await - } - Cmd::SetContactList { contacts } => { - commands::social::cmd_set_contact_list(&client, &contacts).await - } - Cmd::GetEvent { event } => commands::social::cmd_get_event(&client, &event).await, - Cmd::GetUserNotes { - pubkey, - limit, - before, - } => commands::social::cmd_get_user_notes(&client, &pubkey, limit, before).await, - Cmd::GetContactList { pubkey } => { - commands::social::cmd_get_contact_list(&client, &pubkey).await - } + Cmd::Feed(sub) => match sub { + FeedCmd::Get { + since, + limit, + types, + } => commands::feed::cmd_get_feed(&client, since, limit, types.as_deref()).await, + }, - // ---- Git Repos ------------------------------------------------- - Cmd::CreateRepo { - id, - name, - description, - clone_urls, - web, - relays, - } => { - commands::repos::cmd_create_repo( - &client, - &id, - name.as_deref(), - description.as_deref(), - &clone_urls, - web.as_deref(), - &relays, - ) - .await - } - Cmd::GetRepo { id, owner } => { - commands::repos::cmd_get_repo(&client, &id, owner.as_deref()).await - } - Cmd::ListRepos { owner, limit } => { - commands::repos::cmd_list_repos(&client, owner.as_deref(), limit).await - } + Cmd::Social(sub) => match sub { + SocialCmd::PublishNote { content, reply_to } => { + commands::social::cmd_publish_note(&client, &content, reply_to.as_deref()).await + } + SocialCmd::SetContactList { contacts } => { + commands::social::cmd_set_contact_list(&client, &contacts).await + } + SocialCmd::GetEvent { event } => commands::social::cmd_get_event(&client, &event).await, + SocialCmd::GetUserNotes { + pubkey, + limit, + before, + } => commands::social::cmd_get_user_notes(&client, &pubkey, limit, before).await, + SocialCmd::GetContactList { pubkey } => { + commands::social::cmd_get_contact_list(&client, &pubkey).await + } + }, + + Cmd::Repos(sub) => match sub { + ReposCmd::Create { + id, + name, + description, + clone_urls, + web, + relays, + } => { + commands::repos::cmd_create_repo( + &client, + &id, + name.as_deref(), + description.as_deref(), + &clone_urls, + web.as_deref(), + &relays, + ) + .await + } + ReposCmd::Get { id, owner } => { + commands::repos::cmd_get_repo(&client, &id, owner.as_deref()).await + } + ReposCmd::List { owner, limit } => { + commands::repos::cmd_list_repos(&client, owner.as_deref(), limit).await + } + }, + + Cmd::Upload(sub) => match sub { + UploadCmd::File { file } => { + let desc = client.upload_file(&file).await?; + println!( + "{}", + serde_json::to_string_pretty(&desc) + .map_err(|e| CliError::Other(e.to_string()))? + ); + Ok(()) + } + }, - // ---- Pack (local) -------------------------------------------------- Cmd::Pack(_) => unreachable!("handled above"), } } @@ -918,112 +1068,77 @@ mod tests { Cli::command().debug_assert(); } - /// Regression: parse_bool_flag rejects values other than "true"/"false". - #[test] - fn parse_bool_flag_accepts_true() { - assert!(super::parse_bool_flag("--approved", "true").unwrap()); - } - - #[test] - fn parse_bool_flag_accepts_false() { - assert!(!super::parse_bool_flag("--approved", "false").unwrap()); - } - - #[test] - fn parse_bool_flag_rejects_invalid() { - let err = super::parse_bool_flag("--approved", "maybe").unwrap_err(); - match err { - super::CliError::Usage(msg) => { - assert!(msg.contains("must be 'true' or 'false'"), "got: {msg}"); - assert!(msg.contains("maybe"), "got: {msg}"); - } - other => panic!("expected Usage error, got: {other:?}"), - } - } - - #[test] - fn parse_bool_flag_rejects_empty() { - assert!(super::parse_bool_flag("--approved", "").is_err()); - } - - /// Parity: the CLI exposes exactly the expected set of commands. - /// Token commands removed (auth, list-tokens, delete-token, delete-all-tokens). - /// SetChannelAddPolicy removed (relay-side policy now). - /// Cursor removed from get-thread, list-dms. before_id removed from get-user-notes. #[test] fn command_inventory_is_stable() { - let expected: Vec<&str> = vec![ - "add-channel-member", - "add-dm-member", - "add-reaction", - "approve-step", - "archive-channel", - "create-channel", - "create-repo", - "create-workflow", - "delete-channel", - "delete-message", - "delete-workflow", - "edit-message", - "get-canvas", - "get-channel", - "get-contact-list", - "get-event", - "get-feed", - "get-messages", - "get-presence", - "get-reactions", - "get-repo", - "get-thread", - "get-user-notes", - "get-users", - "get-workflow", - "get-workflow-runs", - "join-channel", - "leave-channel", - "list-channel-members", - "list-channels", - "list-dms", - "list-repos", - "list-workflows", - "open-dm", + let expected_groups: Vec<&str> = vec![ + "canvas", + "channels", + "dms", + "feed", + "messages", "pack", - "publish-note", - "remove-channel-member", - "remove-reaction", - "search", - "send-diff-message", - "send-message", - "set-canvas", - "set-channel-purpose", - "set-channel-topic", - "set-contact-list", - "set-presence", - "set-profile", - "trigger-workflow", - "unarchive-channel", - "update-channel", - "update-workflow", - "upload-file", - "vote-on-post", + "reactions", + "repos", + "social", + "upload", + "users", + "workflows", ]; let cmd = Cli::command(); let mut actual: Vec = cmd .get_subcommands() .map(|s| s.get_name().to_string()) - .filter(|n| n != "help") // clap auto-adds "help" + .filter(|n| n != "help") .collect(); actual.sort(); assert_eq!( actual.len(), - expected.len(), - "Expected {} commands, got {}. Actual: {:?}", - expected.len(), + expected_groups.len(), + "Expected {} groups, got {}. Actual: {:?}", + expected_groups.len(), actual.len(), actual ); - assert_eq!(actual, expected, "Command inventory drift detected"); + assert_eq!( + actual, expected_groups, + "Command group inventory drift detected" + ); + } + + #[test] + fn subcommand_counts_are_stable() { + let expected: Vec<(&str, usize)> = vec![ + ("canvas", 2), + ("channels", 14), + ("dms", 3), + ("feed", 1), + ("messages", 8), + ("pack", 2), + ("reactions", 3), + ("repos", 3), + ("social", 5), + ("upload", 1), + ("users", 4), + ("workflows", 8), + ]; + + let cmd = Cli::command(); + for (group_name, expected_count) in &expected { + let group = cmd + .get_subcommands() + .find(|s| s.get_name() == *group_name) + .unwrap_or_else(|| panic!("group '{}' not found", group_name)); + let actual_count = group + .get_subcommands() + .filter(|s| s.get_name() != "help") + .count(); + assert_eq!( + actual_count, *expected_count, + "Group '{}': expected {} subcommands, got {}", + group_name, expected_count, actual_count + ); + } } } diff --git a/crates/sprout-cli/src/validate.rs b/crates/sprout-cli/src/validate.rs index 0903a4088..96dd048da 100644 --- a/crates/sprout-cli/src/validate.rs +++ b/crates/sprout-cli/src/validate.rs @@ -6,6 +6,19 @@ pub const MAX_CONTENT_BYTES: usize = 65_536; /// Maximum diff size in bytes (60 KiB). pub const MAX_DIFF_BYTES: usize = 61_440; +/// Parse a hex string into a `nostr::EventId`. Returns `CliError::Usage` on failure. +pub fn parse_event_id(hex: &str) -> Result { + nostr::EventId::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 channel UUID: {e}"))) +} + /// Validate UUID string. Returns CliError::Usage on failure. pub fn validate_uuid(s: &str) -> Result<(), CliError> { uuid::Uuid::parse_str(s).map_err(|_| CliError::Usage(format!("invalid UUID: {s}")))?; @@ -367,6 +380,31 @@ mod tests { // Note: `extract_at_names`, `merge_mentions`, and `normalize_mention_pubkeys` // moved to `sprout_sdk::mentions` and are tested there. + // --- parse_event_id --- + + #[test] + fn parse_event_id_valid() { + let hex = "a".repeat(64); + assert!(super::parse_event_id(&hex).is_ok()); + } + + #[test] + fn parse_event_id_invalid() { + assert!(super::parse_event_id("not-a-hex-id").is_err()); + } + + // --- parse_uuid --- + + #[test] + fn parse_uuid_valid() { + assert!(super::parse_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok()); + } + + #[test] + fn parse_uuid_invalid() { + assert!(super::parse_uuid("not-a-uuid").is_err()); + } + // ── validate_repo_id ───────────────────────────────────────────────────── #[test] From bc5d844336f5a0e06484b209ce638c43c830a394 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 16:22:26 -0400 Subject: [PATCH 2/6] fix(cli): address 15 review items from 6-source crossfire review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial restructuring commit (9809ebe) was reviewed by 3 Claude specialists, Codex, Gemini, and the plan author. This commit addresses all 15 findings — 1 blocking bug, 10 should-fix, 4 optional. Critical: --approved flag used default_value_t which broke --no-approved negation. Changed to ArgAction::Set accepting --approved true/false. Added doc comments to all ~78 bare arg fields, value_enum types for --type/--visibility/--status, per-module dispatch() delegation (shrinking run() from 370 to 30 lines), social subcommand renames (publish-note→publish, get-event→event, etc.), subcommand name assertions in tests, and stale SPROUT_PUBKEY doc removal. --- crates/sprout-cli/README.md | 28 +- crates/sprout-cli/TESTING.md | 35 +- crates/sprout-cli/src/commands/channels.rs | 74 ++- crates/sprout-cli/src/commands/dms.rs | 13 + crates/sprout-cli/src/commands/feed.rs | 15 + crates/sprout-cli/src/commands/messages.rs | 87 ++- crates/sprout-cli/src/commands/mod.rs | 1 + crates/sprout-cli/src/commands/reactions.rs | 13 + crates/sprout-cli/src/commands/repos.rs | 31 ++ crates/sprout-cli/src/commands/social.rs | 21 + crates/sprout-cli/src/commands/upload.rs | 15 + crates/sprout-cli/src/commands/users.rs | 30 + crates/sprout-cli/src/commands/workflows.rs | 35 ++ crates/sprout-cli/src/lib.rs | 589 ++++++++------------ crates/sprout-cli/src/validate.rs | 2 +- 15 files changed, 586 insertions(+), 403 deletions(-) create mode 100644 crates/sprout-cli/src/commands/upload.rs diff --git a/crates/sprout-cli/README.md b/crates/sprout-cli/README.md index e1e2078c0..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 channels list - -# Option 2: Dev mode (no auth) -export SPROUT_PUBKEY="" -sprout channels list ``` ## Usage @@ -70,7 +63,8 @@ sprout dms list # Workflows sprout workflows list --channel sprout workflows trigger --workflow -sprout workflows approve --token --approved +sprout workflows approve --token +sprout workflows approve --token --approved false --note "needs revision" # Forum sprout messages vote --event --direction up @@ -130,11 +124,11 @@ sprout channels list | jq '.[].name' | | `runs` | Get workflow run history | | | `approve` | Approve/deny a workflow step | | `feed` | `get` | Get your activity feed | -| `social` | `publish-note` | Publish a NIP-01 note | -| | `set-contact-list` | Set NIP-02 contact list | -| | `get-event` | Get a Nostr event | -| | `get-user-notes` | Get notes for a user | -| | `get-contact-list` | Get NIP-02 contact list | +| `social` | `publish` | Publish a NIP-01 note | +| | `set-contacts` | Set NIP-02 contact list | +| | `event` | Get a Nostr event | +| | `notes` | Get notes for a user | +| | `contacts` | Get NIP-02 contact list | | `repos` | `create` | Announce a git repository (NIP-34) | | | `get` | Get a repository announcement | | | `list` | List repository announcements | diff --git a/crates/sprout-cli/TESTING.md b/crates/sprout-cli/TESTING.md index 5dcc82411..a58c2ae90 100644 --- a/crates/sprout-cli/TESTING.md +++ b/crates/sprout-cli/TESTING.md @@ -71,7 +71,6 @@ cargo run -p sprout-admin -- mint-token \ This generates a keypair and prints: - **Private key (nsec)** — save for `SPROUT_PRIVATE_KEY` testing -- **Pubkey** — save for `SPROUT_PUBKEY` testing Export: @@ -100,7 +99,7 @@ export SPROUT_PRIVATE_KEY="nsec1..." # from the mint output ```bash cargo test -p sprout-cli -# Expected: 38 passed, 0 failed +# Expected: see cargo test -p sprout-cli for current count cargo clippy -p sprout-cli -- -D warnings # Expected: zero warnings @@ -366,8 +365,9 @@ sprout workflows runs --workflow "$WF_ID" | jq . # workflows approve — requires a workflow run waiting for approval # This is hard to test ad-hoc without a workflow that has an approval gate. # Test the validation instead: -sprout workflows approve --token "00000000-0000-0000-0000-000000000000" --approved 2>&1 || true +sprout workflows approve --token "00000000-0000-0000-0000-000000000000" 2>&1 || true # Should fail with relay error (token not found), not a validation error +# To test the deny path: sprout workflows approve --token --approved false # workflows delete sprout workflows delete --workflow "$WF_ID" | jq . @@ -428,9 +428,9 @@ sprout users set-profile 2>&1; echo "exit: $?" # exit: 1 (at least one field required) # Exit 3: No auth configured -env -u SPROUT_PRIVATE_KEY -u SPROUT_PUBKEY \ +env -u SPROUT_PRIVATE_KEY \ cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?" -# stderr: {"error":"auth_error","message":"auth error: Set SPROUT_PRIVATE_KEY or SPROUT_PUBKEY"} +# stderr: {"error":"auth_error","message":"SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"} # exit: 3 # Exit 2: Non-existent channel (valid UUID) @@ -441,22 +441,19 @@ sprout channels get --channel "00000000-0000-0000-0000-000000000000" 2>&1; echo --- -## 8. Auth Mode Testing +## 8. Auth Testing -Test both authentication modes. +Test authentication. ```bash -# Mode 1: Private key (SPROUT_PRIVATE_KEY) +# Private key (SPROUT_PRIVATE_KEY) SPROUT_PRIVATE_KEY="nsec1..." sprout channels list | jq . # Should succeed -# Mode 2: Dev mode (SPROUT_PUBKEY) — only works with SPROUT_REQUIRE_AUTH_TOKEN=false -SPROUT_PUBKEY="" sprout channels list | jq . -# Should succeed - # No auth → exit 3 -env -u SPROUT_PRIVATE_KEY -u SPROUT_PUBKEY \ +env -u SPROUT_PRIVATE_KEY \ cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?" +# stderr: {"error":"auth_error","message":"SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"} # exit: 3 ``` @@ -517,13 +514,13 @@ sprout channels delete --channel "$FORUM_ID" | jq . | 39 | `workflows trigger` | ☐ | | | 40 | `workflows runs` | ☐ | | | 41 | `workflows get` | ☐ | | -| 42 | `workflows approve` | ☐ | Validation only (needs approval gate); use --approved / --no-approved | +| 42 | `workflows approve` | ☐ | Validation only (needs approval gate); bare = approve, `--approved false` = deny | | 43 | `feed get` | ☐ | | -| 44 | `social publish-note` | ☐ | | -| 45 | `social set-contact-list` | ☐ | | -| 46 | `social get-event` | ☐ | | -| 47 | `social get-user-notes` | ☐ | | -| 48 | `social get-contact-list` | ☐ | | +| 44 | `social publish` | ☐ | | +| 45 | `social set-contacts` | ☐ | | +| 46 | `social event` | ☐ | | +| 47 | `social notes` | ☐ | | +| 48 | `social contacts` | ☐ | | | 49 | `repos create` | ☐ | | | 50 | `repos get` | ☐ | | | 51 | `repos list` | ☐ | | diff --git a/crates/sprout-cli/src/commands/channels.rs b/crates/sprout-cli/src/commands/channels.rs index f809ef913..17018f8ab 100644 --- a/crates/sprout-cli/src/commands/channels.rs +++ b/crates/sprout-cli/src/commands/channels.rs @@ -129,7 +129,6 @@ pub async fn cmd_update_channel( "at least one field required (--name, --description)".into(), )); } - validate_uuid(channel_id)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_update_channel(channel_uuid, name, description) @@ -146,7 +145,6 @@ pub async fn cmd_set_channel_topic( channel_id: &str, topic: &str, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_set_topic(channel_uuid, topic) @@ -163,7 +161,6 @@ pub async fn cmd_set_channel_purpose( channel_id: &str, purpose: &str, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_set_purpose(channel_uuid, purpose) @@ -176,7 +173,6 @@ pub async fn cmd_set_channel_purpose( } pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { - validate_uuid(channel_id)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_join(channel_uuid) @@ -189,7 +185,6 @@ pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result } pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { - validate_uuid(channel_id)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_leave(channel_uuid) @@ -202,7 +197,6 @@ pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Resul } pub async fn cmd_archive_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { - validate_uuid(channel_id)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_archive(channel_uuid) @@ -218,7 +212,6 @@ pub async fn cmd_unarchive_channel( client: &SproutClient, channel_id: &str, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_unarchive(channel_uuid) @@ -231,7 +224,6 @@ pub async fn cmd_unarchive_channel( } pub async fn cmd_delete_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { - validate_uuid(channel_id)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_delete_channel(channel_uuid) @@ -249,7 +241,6 @@ pub async fn cmd_add_channel_member( pubkey: &str, role: Option<&str>, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; validate_hex64(pubkey)?; let channel_uuid = parse_uuid(channel_id)?; @@ -280,7 +271,6 @@ pub async fn cmd_remove_channel_member( channel_id: &str, pubkey: &str, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; validate_hex64(pubkey)?; let channel_uuid = parse_uuid(channel_id)?; @@ -298,7 +288,6 @@ pub async fn cmd_set_canvas( channel_id: &str, content: &str, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; let content = read_or_stdin(content)?; let channel_uuid = parse_uuid(channel_id)?; @@ -310,3 +299,66 @@ pub async fn cmd_set_canvas( println!("{resp}"); Ok(()) } + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +pub async fn dispatch(cmd: crate::ChannelsCmd, client: &SproutClient) -> Result<(), CliError> { + use crate::ChannelsCmd; + match cmd { + ChannelsCmd::List { visibility, member } => { + let vis_str = visibility.as_ref().map(|v| v.to_string()); + cmd_list_channels(client, vis_str.as_deref(), Some(member)).await + } + ChannelsCmd::Get { channel } => cmd_get_channel(client, &channel).await, + ChannelsCmd::Create { + name, + channel_type, + visibility, + description, + } => { + cmd_create_channel( + client, + &name, + &channel_type.to_string(), + &visibility.to_string(), + description.as_deref(), + ) + .await + } + ChannelsCmd::Update { + channel, + name, + description, + } => cmd_update_channel(client, &channel, name.as_deref(), description.as_deref()).await, + ChannelsCmd::Topic { channel, topic } => { + cmd_set_channel_topic(client, &channel, &topic).await + } + ChannelsCmd::Purpose { channel, purpose } => { + cmd_set_channel_purpose(client, &channel, &purpose).await + } + ChannelsCmd::Join { channel } => cmd_join_channel(client, &channel).await, + ChannelsCmd::Leave { channel } => cmd_leave_channel(client, &channel).await, + ChannelsCmd::Archive { channel } => cmd_archive_channel(client, &channel).await, + ChannelsCmd::Unarchive { channel } => cmd_unarchive_channel(client, &channel).await, + ChannelsCmd::Delete { channel } => cmd_delete_channel(client, &channel).await, + ChannelsCmd::Members { channel } => cmd_list_channel_members(client, &channel).await, + ChannelsCmd::AddMember { + channel, + pubkey, + role, + } => cmd_add_channel_member(client, &channel, &pubkey, role.as_deref()).await, + ChannelsCmd::RemoveMember { channel, pubkey } => { + cmd_remove_channel_member(client, &channel, &pubkey).await + } + } +} + +pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &SproutClient) -> Result<(), CliError> { + use crate::CanvasCmd; + match cmd { + CanvasCmd::Get { channel } => cmd_get_canvas(client, &channel).await, + CanvasCmd::Set { channel, content } => cmd_set_canvas(client, &channel, &content).await, + } +} diff --git a/crates/sprout-cli/src/commands/dms.rs b/crates/sprout-cli/src/commands/dms.rs index 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 5e386a2d2..95a8a415e 100644 --- a/crates/sprout-cli/src/commands/messages.rs +++ b/crates/sprout-cli/src/commands/messages.rs @@ -313,7 +313,6 @@ pub struct SendMessageParams { } pub async fn cmd_send_message(client: &SproutClient, p: SendMessageParams) -> Result<(), CliError> { - validate_uuid(&p.channel_id)?; validate_content_size(&p.content)?; if let Some(ref r) = p.reply_to { validate_hex64(r)?; @@ -399,7 +398,6 @@ pub async fn cmd_send_diff_message( client: &SproutClient, p: SendDiffParams, ) -> Result<(), CliError> { - validate_uuid(&p.channel_id)?; if let Some(r) = &p.reply_to { validate_hex64(r)?; } @@ -543,6 +541,91 @@ pub async fn cmd_vote_on_post( Ok(()) } +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +pub async fn dispatch(cmd: crate::MessagesCmd, client: &SproutClient) -> Result<(), CliError> { + use crate::MessagesCmd; + match cmd { + MessagesCmd::Send { + channel, + content, + kind, + reply_to, + broadcast, + mentions, + files, + } => { + cmd_send_message( + client, + SendMessageParams { + channel_id: channel, + content, + kind, + reply_to, + broadcast, + mentions, + files, + }, + ) + .await + } + MessagesCmd::SendDiff { + channel, + diff, + repo, + commit, + file, + parent_commit, + source_branch, + target_branch, + pr, + lang, + description, + reply_to, + } => { + cmd_send_diff_message( + client, + SendDiffParams { + channel_id: channel, + diff, + repo_url: repo, + commit_sha: commit, + file_path: file, + parent_commit_sha: parent_commit, + source_branch, + target_branch, + pr_number: pr, + language: lang, + description, + reply_to, + }, + ) + .await + } + MessagesCmd::Edit { event, content } => cmd_edit_message(client, &event, &content).await, + MessagesCmd::Delete { event } => cmd_delete_message(client, &event).await, + MessagesCmd::Get { + channel, + limit, + before, + since, + kinds, + } => cmd_get_messages(client, &channel, limit, before, since, kinds.as_deref()).await, + MessagesCmd::Thread { + channel, + event, + depth_limit, + limit, + } => cmd_get_thread(client, &channel, &event, depth_limit, limit).await, + MessagesCmd::Search { query, limit } => cmd_search(client, &query, limit).await, + MessagesCmd::Vote { event, direction } => { + cmd_vote_on_post(client, &event, &direction).await + } + } +} + #[cfg(test)] mod tests { use super::{find_root_from_tags, parse_member_pubkeys}; diff --git a/crates/sprout-cli/src/commands/mod.rs b/crates/sprout-cli/src/commands/mod.rs index 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 dc9a14364..97448cfd7 100644 --- a/crates/sprout-cli/src/commands/social.rs +++ b/crates/sprout-cli/src/commands/social.rs @@ -111,3 +111,24 @@ pub async fn cmd_get_contact_list(client: &SproutClient, pubkey: &str) -> Result println!("{resp}"); Ok(()) } + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +pub async fn dispatch(cmd: crate::SocialCmd, client: &SproutClient) -> Result<(), CliError> { + use crate::SocialCmd; + match cmd { + SocialCmd::PublishNote { content, reply_to } => { + cmd_publish_note(client, &content, reply_to.as_deref()).await + } + SocialCmd::SetContactList { contacts } => cmd_set_contact_list(client, &contacts).await, + SocialCmd::GetEvent { event } => cmd_get_event(client, &event).await, + SocialCmd::GetUserNotes { + pubkey, + limit, + before, + } => cmd_get_user_notes(client, &pubkey, limit, before).await, + SocialCmd::GetContactList { pubkey } => cmd_get_contact_list(client, &pubkey).await, + } +} diff --git a/crates/sprout-cli/src/commands/upload.rs b/crates/sprout-cli/src/commands/upload.rs new file mode 100644 index 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 4e7ce109b..a4068b699 100644 --- a/crates/sprout-cli/src/lib.rs +++ b/crates/sprout-cli/src/lib.rs @@ -72,6 +72,64 @@ struct Cli { command: Cmd, } +// --------------------------------------------------------------------------- +// Value enums for typed --type / --visibility / --status flags +// --------------------------------------------------------------------------- + +#[derive(Clone, clap::ValueEnum)] +pub enum ChannelType { + #[value(name = "stream")] + Stream, + #[value(name = "forum")] + Forum, +} + +impl std::fmt::Display for ChannelType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Stream => write!(f, "stream"), + Self::Forum => write!(f, "forum"), + } + } +} + +#[derive(Clone, clap::ValueEnum)] +pub enum ChannelVisibility { + #[value(name = "open")] + Open, + #[value(name = "private")] + Private, +} + +impl std::fmt::Display for ChannelVisibility { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Open => write!(f, "open"), + Self::Private => write!(f, "private"), + } + } +} + +#[derive(Clone, clap::ValueEnum)] +pub enum PresenceStatus { + #[value(name = "online")] + Online, + #[value(name = "away")] + Away, + #[value(name = "offline")] + Offline, +} + +impl std::fmt::Display for PresenceStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Online => write!(f, "online"), + Self::Away => write!(f, "away"), + Self::Offline => write!(f, "offline"), + } + } +} + // --------------------------------------------------------------------------- // Subcommand groups // --------------------------------------------------------------------------- @@ -124,19 +182,25 @@ enum Cmd { pub enum MessagesCmd { /// Send a message to a channel #[command( - after_help = "Examples:\n sprout messages send --channel --content \"hello\"\n sprout messages send --channel --content \"@alice check this\" --mention alice" + 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 @@ -145,28 +209,40 @@ pub enum MessagesCmd { }, /// Send a code diff / patch to a channel SendDiff { + /// Channel UUID #[arg(long)] channel: String, + /// Diff/patch content (use '-' to read from stdin) #[arg(long)] diff: String, + /// Repository URL (e.g. https://github.com/org/repo) #[arg(long)] repo: String, + /// Commit SHA #[arg(long)] commit: String, + /// Single file path within the repo #[arg(long)] file: Option, + /// 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, }, @@ -181,6 +257,7 @@ pub enum MessagesCmd { }, /// Delete a message by event ID Delete { + /// Event ID to delete (64-char hex) #[arg(long)] event: String, }, @@ -189,13 +266,16 @@ pub enum MessagesCmd { after_help = "Examples:\n sprout messages get --channel \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) @@ -204,10 +284,13 @@ pub enum MessagesCmd { }, /// Get a message thread (replies to a root message) Thread { + /// Channel UUID #[arg(long)] channel: String, + /// Root message event ID (64-char hex) #[arg(long)] event: String, + /// Maximum reply depth to traverse #[arg(long)] depth_limit: Option, /// Maximum number of results to return @@ -216,6 +299,7 @@ pub enum MessagesCmd { }, /// Full-text search across messages Search { + /// Search query string #[arg(long)] query: String, /// Maximum number of results to return @@ -244,15 +328,16 @@ pub enum ChannelsCmd { after_help = "Examples:\n sprout channels list\n sprout channels list --visibility open" )] List { - /// Filter by visibility (e.g. open, closed) - #[arg(long)] - visibility: Option, + /// 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 details for a single channel Get { + /// Channel UUID #[arg(long)] channel: String, }, @@ -261,83 +346,105 @@ pub enum ChannelsCmd { after_help = "Examples:\n sprout channels create --name general --type stream --visibility open\n sprout channels create --name design --type forum --visibility open --description \"Design discussions\"" )] Create { + /// Channel name #[arg(long)] name: String, - #[arg(long = "type")] - channel_type: String, - #[arg(long)] - visibility: String, + /// Channel type + #[arg(long = "type", value_enum)] + channel_type: ChannelType, + /// Channel visibility + #[arg(long, value_enum)] + visibility: ChannelVisibility, + /// Channel description #[arg(long)] description: Option, }, /// 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 the channel topic Topic { + /// Channel UUID #[arg(long)] channel: String, + /// New topic text #[arg(long)] topic: String, }, /// Set the channel purpose Purpose { + /// Channel UUID #[arg(long)] channel: String, + /// New purpose text #[arg(long)] purpose: String, }, /// Join a channel Join { + /// Channel UUID #[arg(long)] channel: String, }, /// Leave a channel Leave { + /// Channel UUID #[arg(long)] channel: String, }, /// Archive a channel Archive { + /// Channel UUID #[arg(long)] channel: String, }, /// Unarchive a channel Unarchive { + /// Channel UUID #[arg(long)] channel: String, }, /// Delete a channel permanently Delete { + /// Channel UUID #[arg(long)] channel: String, }, /// List members of a channel Members { + /// Channel UUID #[arg(long)] channel: String, }, /// Add a member to a channel #[command(name = "add-member")] AddMember { + /// Channel UUID #[arg(long)] channel: String, + /// Member pubkey (64-char hex) #[arg(long)] pubkey: String, + /// Member role (owner, admin, member, guest, bot) #[arg(long)] role: Option, }, /// Remove a member from a channel #[command(name = "remove-member")] RemoveMember { + /// Channel UUID #[arg(long)] channel: String, + /// Member pubkey (64-char hex) #[arg(long)] pubkey: String, }, @@ -351,13 +458,16 @@ pub enum ChannelsCmd { pub enum CanvasCmd { /// Get the canvas document for a channel Get { + /// Channel UUID #[arg(long)] channel: String, }, /// Set (replace) the canvas document for a channel Set { + /// Channel UUID #[arg(long)] channel: String, + /// Canvas content (markdown; use '-' to read from stdin) #[arg(long)] content: String, }, @@ -371,20 +481,25 @@ pub enum CanvasCmd { pub enum ReactionsCmd { /// Add an emoji reaction to a message Add { + /// Event ID (64-char hex) #[arg(long)] event: String, + /// Emoji character (e.g. '👍') #[arg(long)] emoji: String, }, /// Remove an emoji reaction from a message Remove { + /// Event ID (64-char hex) #[arg(long)] event: String, + /// Emoji character to remove #[arg(long)] emoji: String, }, /// List reactions on a message Get { + /// Event ID (64-char hex) #[arg(long)] event: String, }, @@ -404,13 +519,16 @@ pub enum DmsCmd { }, /// Open a new direct message with one or more users Open { + /// User pubkey(s) to DM (64-char hex, 1-8) #[arg(long = "pubkey")] pubkeys: Vec, }, /// Add a member to an existing DM conversation AddMember { + /// DM conversation UUID #[arg(long)] channel: String, + /// User pubkey to add (64-char hex) #[arg(long)] pubkey: String, }, @@ -424,6 +542,7 @@ pub enum DmsCmd { pub enum UsersCmd { /// Look up user profiles by pubkey or name Get { + /// User pubkey(s) to look up (64-char hex). Omit for your own profile #[arg(long = "pubkey")] pubkeys: Vec, /// Search by display name (case-insensitive substring match) @@ -433,25 +552,31 @@ pub enum UsersCmd { /// Update the current identity's profile #[command(name = "set-profile")] SetProfile { + /// Display name #[arg(long)] name: Option, + /// 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 Presence { + /// Comma-separated pubkeys (64-char hex) #[arg(long)] pubkeys: String, }, /// Set your presence status (online/away/offline) #[command(name = "set-presence")] SetPresence { - #[arg(long)] - status: String, + /// Presence status + #[arg(long, value_enum)] + status: PresenceStatus, }, } @@ -463,43 +588,52 @@ pub enum UsersCmd { pub enum WorkflowsCmd { /// List workflows in a channel List { + /// Channel UUID #[arg(long)] channel: String, }, /// Get details for a single workflow Get { + /// Workflow UUID #[arg(long)] workflow: String, }, /// Create a workflow from a YAML definition Create { + /// Channel UUID #[arg(long)] channel: String, + /// Workflow YAML definition #[arg(long)] yaml: String, }, /// Update a workflow's YAML definition Update { + /// Workflow UUID #[arg(long)] channel: String, #[arg(long)] workflow: String, + /// Updated workflow YAML definition #[arg(long)] yaml: String, }, /// Delete a workflow Delete { + /// Workflow UUID #[arg(long)] workflow: String, }, /// Trigger a workflow run #[command(after_help = "Examples:\n sprout workflows trigger --workflow ")] Trigger { + /// Workflow UUID #[arg(long)] workflow: String, }, /// List runs for a workflow Runs { + /// Workflow UUID #[arg(long)] workflow: String, /// Maximum number of results to return @@ -508,14 +642,14 @@ pub enum WorkflowsCmd { }, /// Approve or deny a workflow step #[command( - after_help = "Examples:\n sprout workflows approve --token \n sprout workflows approve --token --no-approved --note \"needs revision\"" + 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, - /// Approve the step (pass --no-approved to deny) - #[arg(long, default_value_t = true)] + /// Approve (true) or deny (false) the step + #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] approved: bool, /// Optional note to include with the approval/denial #[arg(long)] @@ -531,11 +665,13 @@ pub enum WorkflowsCmd { pub enum FeedCmd { /// Get recent activity feed entries Get { + /// Unix timestamp — return entries after this time #[arg(long)] since: Option, /// Maximum number of results to return #[arg(long)] limit: Option, + /// Comma-separated feed entry types to filter #[arg(long)] types: Option, }, @@ -548,7 +684,7 @@ pub enum FeedCmd { #[derive(Subcommand)] pub enum SocialCmd { /// Publish a text note (NIP-01 kind:1) - #[command(name = "publish-note")] + #[command(name = "publish")] PublishNote { /// Text content of the note. #[arg(long)] @@ -558,21 +694,21 @@ pub enum SocialCmd { reply_to: Option, }, /// Set your contact list (NIP-02 kind:3) - #[command(name = "set-contact-list")] + #[command(name = "set-contacts")] SetContactList { /// JSON array of contacts: [{"pubkey":"hex","relay_url":"...","petname":"..."}] #[arg(long)] contacts: String, }, /// Get a single event by ID - #[command(name = "get-event")] + #[command(name = "event")] GetEvent { /// 64-char hex event ID. #[arg(long)] event: String, }, /// Get recent notes published by a user - #[command(name = "get-user-notes")] + #[command(name = "notes")] GetUserNotes { /// 64-char hex pubkey of the author. #[arg(long)] @@ -585,7 +721,7 @@ pub enum SocialCmd { before: Option, }, /// Get a user's contact list - #[command(name = "get-contact-list")] + #[command(name = "contacts")] GetContactList { /// 64-char hex pubkey. #[arg(long)] @@ -660,7 +796,7 @@ pub enum UploadCmd { /// Subcommands for `sprout pack`. #[derive(Subcommand)] -enum PackCmd { +pub enum PackCmd { /// Validate a persona pack directory Validate { /// Path to the pack directory @@ -715,340 +851,17 @@ async fn run(cli: Cli) -> Result<(), CliError> { let client = SproutClient::new(relay_url, keys, auth_tag, auth_tag_json)?; match cli.command { - Cmd::Messages(sub) => match sub { - MessagesCmd::Send { - channel, - content, - kind, - reply_to, - broadcast, - mentions, - files, - } => { - commands::messages::cmd_send_message( - &client, - commands::messages::SendMessageParams { - channel_id: channel, - content, - kind, - reply_to, - broadcast, - mentions, - files, - }, - ) - .await - } - MessagesCmd::SendDiff { - channel, - diff, - repo, - commit, - file, - parent_commit, - source_branch, - target_branch, - pr, - lang, - description, - reply_to, - } => { - commands::messages::cmd_send_diff_message( - &client, - commands::messages::SendDiffParams { - channel_id: channel, - diff, - repo_url: repo, - commit_sha: commit, - file_path: file, - parent_commit_sha: parent_commit, - source_branch, - target_branch, - pr_number: pr, - language: lang, - description, - reply_to, - }, - ) - .await - } - MessagesCmd::Edit { event, content } => { - commands::messages::cmd_edit_message(&client, &event, &content).await - } - MessagesCmd::Delete { event } => { - commands::messages::cmd_delete_message(&client, &event).await - } - MessagesCmd::Get { - channel, - limit, - before, - since, - kinds, - } => { - commands::messages::cmd_get_messages( - &client, - &channel, - limit, - before, - since, - kinds.as_deref(), - ) - .await - } - MessagesCmd::Thread { - channel, - event, - depth_limit, - limit, - } => { - commands::messages::cmd_get_thread(&client, &channel, &event, depth_limit, limit) - .await - } - MessagesCmd::Search { query, limit } => { - commands::messages::cmd_search(&client, &query, limit).await - } - MessagesCmd::Vote { event, direction } => { - commands::messages::cmd_vote_on_post(&client, &event, &direction).await - } - }, - - Cmd::Channels(sub) => match sub { - ChannelsCmd::List { visibility, member } => { - commands::channels::cmd_list_channels(&client, visibility.as_deref(), Some(member)) - .await - } - ChannelsCmd::Get { channel } => { - commands::channels::cmd_get_channel(&client, &channel).await - } - ChannelsCmd::Create { - name, - channel_type, - visibility, - description, - } => { - commands::channels::cmd_create_channel( - &client, - &name, - &channel_type, - &visibility, - description.as_deref(), - ) - .await - } - ChannelsCmd::Update { - channel, - name, - description, - } => { - commands::channels::cmd_update_channel( - &client, - &channel, - name.as_deref(), - description.as_deref(), - ) - .await - } - ChannelsCmd::Topic { channel, topic } => { - commands::channels::cmd_set_channel_topic(&client, &channel, &topic).await - } - ChannelsCmd::Purpose { channel, purpose } => { - commands::channels::cmd_set_channel_purpose(&client, &channel, &purpose).await - } - ChannelsCmd::Join { channel } => { - commands::channels::cmd_join_channel(&client, &channel).await - } - ChannelsCmd::Leave { channel } => { - commands::channels::cmd_leave_channel(&client, &channel).await - } - ChannelsCmd::Archive { channel } => { - commands::channels::cmd_archive_channel(&client, &channel).await - } - ChannelsCmd::Unarchive { channel } => { - commands::channels::cmd_unarchive_channel(&client, &channel).await - } - ChannelsCmd::Delete { channel } => { - commands::channels::cmd_delete_channel(&client, &channel).await - } - ChannelsCmd::Members { channel } => { - commands::channels::cmd_list_channel_members(&client, &channel).await - } - ChannelsCmd::AddMember { - channel, - pubkey, - role, - } => { - commands::channels::cmd_add_channel_member( - &client, - &channel, - &pubkey, - role.as_deref(), - ) - .await - } - ChannelsCmd::RemoveMember { channel, pubkey } => { - commands::channels::cmd_remove_channel_member(&client, &channel, &pubkey).await - } - }, - - Cmd::Canvas(sub) => match sub { - CanvasCmd::Get { channel } => { - commands::channels::cmd_get_canvas(&client, &channel).await - } - CanvasCmd::Set { channel, content } => { - commands::channels::cmd_set_canvas(&client, &channel, &content).await - } - }, - - Cmd::Reactions(sub) => match sub { - ReactionsCmd::Add { event, emoji } => { - commands::reactions::cmd_add_reaction(&client, &event, &emoji).await - } - ReactionsCmd::Remove { event, emoji } => { - commands::reactions::cmd_remove_reaction(&client, &event, &emoji).await - } - ReactionsCmd::Get { event } => { - commands::reactions::cmd_get_reactions(&client, &event).await - } - }, - - Cmd::Dms(sub) => match sub { - DmsCmd::List { limit } => commands::dms::cmd_list_dms(&client, limit).await, - DmsCmd::Open { pubkeys } => commands::dms::cmd_open_dm(&client, &pubkeys).await, - DmsCmd::AddMember { channel, pubkey } => { - commands::dms::cmd_add_dm_member(&client, &channel, &pubkey).await - } - }, - - Cmd::Users(sub) => match sub { - UsersCmd::Get { pubkeys, name } => { - commands::users::cmd_get_users(&client, &pubkeys, name.as_deref()).await - } - UsersCmd::SetProfile { - name, - avatar, - about, - nip05, - } => { - commands::users::cmd_set_profile( - &client, - name.as_deref(), - avatar.as_deref(), - about.as_deref(), - nip05.as_deref(), - ) - .await - } - UsersCmd::Presence { pubkeys } => { - commands::users::cmd_get_presence(&client, &pubkeys).await - } - UsersCmd::SetPresence { status } => { - commands::users::cmd_set_presence(&client, &status).await - } - }, - - Cmd::Workflows(sub) => match sub { - WorkflowsCmd::List { channel } => { - commands::workflows::cmd_list_workflows(&client, &channel).await - } - WorkflowsCmd::Get { workflow } => { - commands::workflows::cmd_get_workflow(&client, &workflow).await - } - WorkflowsCmd::Create { channel, yaml } => { - commands::workflows::cmd_create_workflow(&client, &channel, &yaml).await - } - WorkflowsCmd::Update { - channel, - workflow, - yaml, - } => { - commands::workflows::cmd_update_workflow(&client, &channel, &workflow, &yaml).await - } - WorkflowsCmd::Delete { workflow } => { - commands::workflows::cmd_delete_workflow(&client, &workflow).await - } - WorkflowsCmd::Trigger { workflow } => { - commands::workflows::cmd_trigger_workflow(&client, &workflow).await - } - WorkflowsCmd::Runs { workflow, limit } => { - commands::workflows::cmd_get_workflow_runs(&client, &workflow, limit).await - } - WorkflowsCmd::Approve { - token, - approved, - note, - } => { - // approved is already a bool — no parse_bool_flag needed - commands::workflows::cmd_approve_step(&client, &token, approved, note.as_deref()) - .await - } - }, - - Cmd::Feed(sub) => match sub { - FeedCmd::Get { - since, - limit, - types, - } => commands::feed::cmd_get_feed(&client, since, limit, types.as_deref()).await, - }, - - Cmd::Social(sub) => match sub { - SocialCmd::PublishNote { content, reply_to } => { - commands::social::cmd_publish_note(&client, &content, reply_to.as_deref()).await - } - SocialCmd::SetContactList { contacts } => { - commands::social::cmd_set_contact_list(&client, &contacts).await - } - SocialCmd::GetEvent { event } => commands::social::cmd_get_event(&client, &event).await, - SocialCmd::GetUserNotes { - pubkey, - limit, - before, - } => commands::social::cmd_get_user_notes(&client, &pubkey, limit, before).await, - SocialCmd::GetContactList { pubkey } => { - commands::social::cmd_get_contact_list(&client, &pubkey).await - } - }, - - Cmd::Repos(sub) => match sub { - ReposCmd::Create { - id, - name, - description, - clone_urls, - web, - relays, - } => { - commands::repos::cmd_create_repo( - &client, - &id, - name.as_deref(), - description.as_deref(), - &clone_urls, - web.as_deref(), - &relays, - ) - .await - } - ReposCmd::Get { id, owner } => { - commands::repos::cmd_get_repo(&client, &id, owner.as_deref()).await - } - ReposCmd::List { owner, limit } => { - commands::repos::cmd_list_repos(&client, owner.as_deref(), limit).await - } - }, - - Cmd::Upload(sub) => match sub { - UploadCmd::File { file } => { - let desc = client.upload_file(&file).await?; - println!( - "{}", - serde_json::to_string_pretty(&desc) - .map_err(|e| CliError::Other(e.to_string()))? - ); - Ok(()) - } - }, - + Cmd::Messages(sub) => commands::messages::dispatch(sub, &client).await, + Cmd::Channels(sub) => commands::channels::dispatch(sub, &client).await, + Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await, + Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await, + Cmd::Dms(sub) => commands::dms::dispatch(sub, &client).await, + Cmd::Users(sub) => commands::users::dispatch(sub, &client).await, + Cmd::Workflows(sub) => commands::workflows::dispatch(sub, &client).await, + Cmd::Feed(sub) => commands::feed::dispatch(sub, &client).await, + Cmd::Social(sub) => commands::social::dispatch(sub, &client).await, + Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await, + Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await, Cmd::Pack(_) => unreachable!("handled above"), } } @@ -1107,6 +920,76 @@ mod tests { ); } + #[test] + fn subcommand_names_are_stable() { + fn names(cmd: &clap::Command, group: &str) -> Vec { + 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![ diff --git a/crates/sprout-cli/src/validate.rs b/crates/sprout-cli/src/validate.rs index 96dd048da..6d152729b 100644 --- a/crates/sprout-cli/src/validate.rs +++ b/crates/sprout-cli/src/validate.rs @@ -16,7 +16,7 @@ pub fn parse_event_id(hex: &str) -> Result { /// 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 channel UUID: {e}"))) + uuid::Uuid::parse_str(s).map_err(|e| CliError::Usage(format!("invalid UUID: {e}"))) } /// Validate UUID string. Returns CliError::Usage on failure. From 444080cbecaca1db84d779122285fddafbd0ea0d Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 17:20:02 -0400 Subject: [PATCH 3/6] fix(cli): rename --relay to --nostr-relay on repos create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subcommand-level --relay flag on `repos create` shadowed the global --relay flag (Sprout API endpoint). Renamed to --nostr-relay to avoid clap parsing ambiguity — these are NIP-34 preferred relays for repo discovery, not the Sprout relay URL. --- crates/sprout-cli/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs index a4068b699..f70c4a05e 100644 --- a/crates/sprout-cli/src/lib.rs +++ b/crates/sprout-cli/src/lib.rs @@ -752,8 +752,8 @@ pub enum ReposCmd { /// Web browsing URL #[arg(long)] web: Option, - /// 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 announcement From 3d1968ae902632a402705949d9b787860cbd6ae1 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 17:28:45 -0400 Subject: [PATCH 4/6] feat(cli): add long_about with env vars, exit codes, and error format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds structured help text to the top-level sprout --help showing configuration env vars, exit code table, and JSON error format — useful for both humans and agents parsing CLI output programmatically. --- crates/sprout-cli/src/lib.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs index f70c4a05e..ef3ecdbb5 100644 --- a/crates/sprout-cli/src/lib.rs +++ b/crates/sprout-cli/src/lib.rs @@ -50,7 +50,22 @@ where // --------------------------------------------------------------------------- #[derive(Parser)] -#[command(name = "sprout", about = "Sprout CLI — interact with a Sprout relay")] +#[command( + name = "sprout", + about = "Sprout CLI — interact with a Sprout relay", + long_about = "\ +Sprout CLI — interact with a Sprout relay + +Configuration (flags override env vars): + SPROUT_RELAY_URL Relay base URL [default: http://localhost:3000] + SPROUT_PRIVATE_KEY Nostr private key (hex or nsec) [required] + SPROUT_AUTH_TAG NIP-OA auth tag JSON [optional] + +The 'pack' subcommand runs locally and does not require a relay connection. + +Exit codes: 0=ok 1=bad input 2=relay/network error 3=auth error 4=other +Errors are JSON on stderr: {\"error\": \"\", \"message\": \"\"}" +)] struct Cli { /// Relay URL (http:// or https://). Overrides SPROUT_RELAY_URL env var. #[arg( From a4942a735580eddbdb51e2d23f39fe1ab65e3a64 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 18:58:12 -0400 Subject: [PATCH 5/6] fix: remove duplicate parse_uuid after rebase on #589 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both branches added parse_uuid to validate.rs — keep the version with the more descriptive error message (includes parse error detail). --- crates/sprout-cli/src/validate.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/sprout-cli/src/validate.rs b/crates/sprout-cli/src/validate.rs index 6d152729b..ee2d9a5d5 100644 --- a/crates/sprout-cli/src/validate.rs +++ b/crates/sprout-cli/src/validate.rs @@ -25,11 +25,6 @@ pub fn validate_uuid(s: &str) -> Result<(), CliError> { Ok(()) } -/// Parse and validate a UUID string, returning the parsed Uuid value. -pub fn parse_uuid(s: &str) -> Result { - uuid::Uuid::parse_str(s).map_err(|_| CliError::Usage(format!("invalid UUID: {s}"))) -} - /// Validate 64-character lowercase hex string (event_id, pubkey). pub fn validate_hex64(s: &str) -> Result<(), CliError> { if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) { From 004834784dfff59b18eef26aecfd72bc964d663c Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 14 May 2026 18:59:05 -0400 Subject: [PATCH 6/6] fix(cli): correct help text for workflows update --channel The channel field had "Workflow UUID" as its doc comment instead of "Channel UUID the workflow belongs to". Also adds missing doc comment on the workflow field. --- crates/sprout-cli/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs index ef3ecdbb5..fe16cbdfc 100644 --- a/crates/sprout-cli/src/lib.rs +++ b/crates/sprout-cli/src/lib.rs @@ -624,9 +624,10 @@ pub enum WorkflowsCmd { }, /// Update a workflow's YAML definition Update { - /// Workflow UUID + /// Channel UUID the workflow belongs to #[arg(long)] channel: String, + /// Workflow UUID #[arg(long)] workflow: String, /// Updated workflow YAML definition