From bcc87e44dc3af3171ddfa129c3169ef2131bcf5f Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Sat, 21 Mar 2026 20:26:45 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20sprout-cli=20=E2=80=94=20agent-firs?= =?UTF-8?q?t=20CLI=20with=20full=20MCP=20parity=20(48=20commands)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete rewrite of sprout-cli as an agent-first, JSON-only CLI covering all 42 MCP tools plus 6 CLI-only commands (token management, auth, get-workflow, delete-channel). ## CLI (new crate: sprout-cli) - 48 flat commands matching MCP tool names (sprout send-message, etc.) - Pure JSON stdout, structured JSON errors on stderr - Exit codes: 0=ok, 1=user, 2=network, 3=auth, 4=other - Three-tier auth: SPROUT_API_TOKEN > SPROUT_PRIVATE_KEY (auto-mint) > SPROUT_PUBKEY - Input validation before every network call (UUID, hex64, content size, enums) - Single request() method — no HTTP method duplication - 38 unit tests, clippy clean ## Relay (2 new REST endpoints) - PUT /api/messages/{event_id} — edit message (kind:40003) - POST /api/messages/{event_id}/votes — vote on forum post (kind:45002) These were previously WebSocket-only. Adding REST endpoints makes the relay API feature-complete and enables the CLI to cover all 42 MCP tools without WebSocket/Nostr signing complexity. ## Why a rewrite The previous CLI scored 3/10 in crossfire review (Opus + Codex): - Did not compile (5 errors) - 13 commands vs 42 MCP tools - Token minted on every invocation - No input validation - Architecture doc described a different app The new CLI was crossfired to 9-10/10 (Opus 9/10, Codex 10/10). --- Cargo.lock | 16 + Cargo.toml | 1 + crates/sprout-cli/Cargo.toml | 37 + crates/sprout-cli/README.md | 163 ++++ crates/sprout-cli/src/client.rs | 224 ++++++ crates/sprout-cli/src/commands/auth.rs | 116 +++ crates/sprout-cli/src/commands/channels.rs | 228 ++++++ crates/sprout-cli/src/commands/dms.rs | 47 ++ crates/sprout-cli/src/commands/feed.rs | 20 + crates/sprout-cli/src/commands/messages.rs | 240 ++++++ crates/sprout-cli/src/commands/mod.rs | 8 + crates/sprout-cli/src/commands/reactions.rs | 42 + crates/sprout-cli/src/commands/users.rs | 111 +++ crates/sprout-cli/src/commands/workflows.rs | 99 +++ crates/sprout-cli/src/error.rs | 71 ++ crates/sprout-cli/src/main.rs | 835 ++++++++++++++++++++ crates/sprout-cli/src/validate.rs | 382 +++++++++ crates/sprout-relay/src/api/messages.rs | 214 +++++ crates/sprout-relay/src/api/mod.rs | 4 +- crates/sprout-relay/src/router.rs | 9 +- 20 files changed, 2863 insertions(+), 4 deletions(-) create mode 100644 crates/sprout-cli/Cargo.toml create mode 100644 crates/sprout-cli/README.md create mode 100644 crates/sprout-cli/src/client.rs create mode 100644 crates/sprout-cli/src/commands/auth.rs create mode 100644 crates/sprout-cli/src/commands/channels.rs create mode 100644 crates/sprout-cli/src/commands/dms.rs create mode 100644 crates/sprout-cli/src/commands/feed.rs create mode 100644 crates/sprout-cli/src/commands/messages.rs create mode 100644 crates/sprout-cli/src/commands/mod.rs create mode 100644 crates/sprout-cli/src/commands/reactions.rs create mode 100644 crates/sprout-cli/src/commands/users.rs create mode 100644 crates/sprout-cli/src/commands/workflows.rs create mode 100644 crates/sprout-cli/src/error.rs create mode 100644 crates/sprout-cli/src/main.rs create mode 100644 crates/sprout-cli/src/validate.rs diff --git a/Cargo.lock b/Cargo.lock index 3c0e58ac..ca0acc5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3363,6 +3363,22 @@ dependencies = [ "uuid", ] +[[package]] +name = "sprout-cli" +version = "0.1.0" +dependencies = [ + "base64", + "clap", + "nostr", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "uuid", +] + [[package]] name = "sprout-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b0f5e939..34ee7457 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/sprout-admin", "crates/sprout-workflow", "crates/sprout-media", + "crates/sprout-cli", ] exclude = ["desktop/src-tauri"] resolver = "2" diff --git a/crates/sprout-cli/Cargo.toml b/crates/sprout-cli/Cargo.toml new file mode 100644 index 00000000..089ac66c --- /dev/null +++ b/crates/sprout-cli/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "sprout-cli" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "sprout" +path = "src/main.rs" + +[dependencies] +# CLI argument parsing — derive macros + env var support (SPROUT_API_TOKEN auto-wired) +clap = { version = "4", features = ["derive", "env"] } + +# HTTP client — async REST calls to the relay +reqwest = { workspace = true, features = ["json"] } + +# Async runtime — tokio macros + multi-thread for reqwest +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +# Serialization — JSON body building and response passthrough +serde = { workspace = true } +serde_json = { workspace = true } + +# Structured error types with exit code mapping +thiserror = { workspace = true } + +# Nostr event signing — used in `sprout auth` and auto-mint (NIP-98 token minting) +nostr = { workspace = true } + +# UUID parsing for validate_uuid +uuid = { version = "1" } + +# Base64 encoding — NIP-98 event serialization for Authorization header +base64 = "0.22" + +# SHA-256 — NIP-98 payload hash tag +sha2 = "0.10" diff --git a/crates/sprout-cli/README.md b/crates/sprout-cli/README.md new file mode 100644 index 00000000..d1c6cda0 --- /dev/null +++ b/crates/sprout-cli/README.md @@ -0,0 +1,163 @@ +# Sprout CLI + +Agent-first command-line interface for Sprout relay. JSON in, JSON out. + +## Install + +```bash +cargo install --path crates/sprout-cli +``` + +## Authentication + +Three modes, checked in order: + +| Priority | Env Var | Mode | Use Case | +|----------|---------|------|----------| +| 1 | `SPROUT_API_TOKEN` | Bearer token | Production — fastest, no extra HTTP call | +| 2 | `SPROUT_PRIVATE_KEY` | Auto-mint short-lived token via NIP-98 | Agents with a keypair | +| 3 | `SPROUT_PUBKEY` | X-Pubkey header (dev relay only) | Local development | + +```bash +# Option 1: Pre-minted token +export SPROUT_API_TOKEN="sprout_tok_..." +sprout list-channels + +# Option 2: Private key (auto-mints a 1-day token at startup) +export SPROUT_PRIVATE_KEY="nsec1..." +sprout list-channels + +# Option 3: Mint a long-lived token explicitly +export SPROUT_API_TOKEN=$(SPROUT_PRIVATE_KEY=nsec1... sprout auth) +``` + +## Usage + +All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=user error, 2=network, 3=auth, 4=other. + +```bash +# Set relay URL (defaults to http://localhost:3000) +export SPROUT_RELAY_URL="https://relay.example.com" + +# Messages +sprout send-message --channel --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 + +# Diffs +sprout send-diff-message --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" + +# Reactions +sprout add-reaction --event --emoji "👍" +sprout get-reactions --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 + +# DMs +sprout open-dm --pubkey +sprout list-dms + +# Workflows +sprout list-workflows --channel +sprout trigger-workflow --workflow +sprout approve-step --token --approved true + +# Forum +sprout vote-on-post --event --direction up + +# Canvas +sprout get-canvas --channel +sprout set-canvas --channel --content "# Welcome" + +# Tokens +sprout auth # mint token, print to stdout +sprout list-tokens +sprout delete-token --id +sprout delete-all-tokens + +# Pipe to jq +sprout list-channels | jq '.[].name' +``` + +## All 48 Commands + +| Command | Description | +|---------|-------------| +| `send-message` | Send a message to a channel | +| `send-diff-message` | Send a code diff with metadata | +| `edit-message` | Edit a message you sent | +| `delete-message` | Delete a message | +| `get-messages` | List messages in a channel | +| `get-thread` | Get a message thread | +| `search` | Full-text search | +| `list-channels` | List channels | +| `get-channel` | Get channel details | +| `create-channel` | Create a channel | +| `update-channel` | Update channel name/description | +| `set-channel-topic` | Set channel topic | +| `set-channel-purpose` | Set channel purpose | +| `join-channel` | Join a channel | +| `leave-channel` | Leave a channel | +| `archive-channel` | Archive a channel | +| `unarchive-channel` | Unarchive a channel | +| `delete-channel` | Delete a channel | +| `list-channel-members` | List channel members | +| `add-channel-member` | Add a member | +| `remove-channel-member` | Remove a member | +| `get-canvas` | Get channel canvas | +| `set-canvas` | Set channel canvas | +| `add-reaction` | React to a message | +| `remove-reaction` | Remove a reaction | +| `get-reactions` | List reactions | +| `list-dms` | List DM conversations | +| `open-dm` | Open a DM (1–8 pubkeys) | +| `add-dm-member` | Add member to DM group | +| `get-users` | Get user profile(s) | +| `set-profile` | Update your profile | +| `get-presence` | Get presence status | +| `set-presence` | Set presence status | +| `set-channel-add-policy` | Set who can add you to channels | +| `list-workflows` | List workflows | +| `create-workflow` | Create a workflow | +| `update-workflow` | Update a workflow | +| `delete-workflow` | Delete a workflow | +| `trigger-workflow` | Trigger a workflow | +| `get-workflow-runs` | Get workflow run history | +| `get-workflow` | Get workflow definition | +| `approve-step` | Approve/deny a workflow step | +| `get-feed` | Get your activity feed | +| `vote-on-post` | Vote on a forum post | +| `auth` | Mint a long-lived API token | +| `list-tokens` | List your API tokens | +| `delete-token` | Delete a token | +| `delete-all-tokens` | Delete all tokens | + +## Architecture + +``` +sprout [flags] + │ + ├─ main.rs ──▶ commands/*.rs ──▶ client.rs ──▶ Sprout Relay REST API + │ (clap) (handlers) (reqwest) + │ + ├─ validate.rs (UUID, hex, content size, percent-encode) + └─ error.rs (CliError → JSON stderr + exit code) + +stdout: raw relay JSON +stderr: {"error": "category", "message": "detail"} +exit: 0=ok 1=user 2=network 3=auth 4=other +``` diff --git a/crates/sprout-cli/src/client.rs b/crates/sprout-cli/src/client.rs new file mode 100644 index 00000000..ed991886 --- /dev/null +++ b/crates/sprout-cli/src/client.rs @@ -0,0 +1,224 @@ +use std::time::Duration; + +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +pub enum Auth { + Bearer(String), // SPROUT_API_TOKEN or auto-minted via SPROUT_PRIVATE_KEY + DevMode(String), // SPROUT_PUBKEY — X-Pubkey header, relay must have require_auth_token=false +} + +// --------------------------------------------------------------------------- +// SproutClient +// --------------------------------------------------------------------------- + +pub struct SproutClient { + http: reqwest::Client, + relay_url: String, // base URL, no trailing slash, e.g. "https://relay.sprout.place" + auth: Auth, +} + +impl SproutClient { + pub fn new(relay_url: String, auth: Auth) -> Result { + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .build() + .map_err(|e| CliError::Other(e.to_string()))?; + Ok(Self { + http, + relay_url, + auth, + }) + } + + // ----------------------------------------------------------------------- + // Core request method + // ----------------------------------------------------------------------- + + async fn request( + &self, + method: reqwest::Method, + path: &str, + body: Option<&serde_json::Value>, + ) -> Result { + let url = format!("{}{}", self.relay_url, path); + + let builder = self.http.request(method, &url); + let builder = self.apply_auth(builder); + let builder = match body { + Some(b) => builder.json(b), + None => builder, + }; + + let resp = builder.send().await?; // reqwest::Error → CliError::Network via From + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + // Try to extract relay's error message from JSON body + let message = serde_json::from_str::(&body) + .ok() + .and_then(|v| { + v.get("error") + .or_else(|| v.get("message")) + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or(body); + return Err(CliError::Relay { + status, + body: message, + }); + } + + Ok(resp.text().await?) + } + + fn apply_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + match &self.auth { + Auth::Bearer(token) => builder.header("Authorization", format!("Bearer {}", token)), + Auth::DevMode(pk) => builder.header("X-Pubkey", pk), + } + } + + // ----------------------------------------------------------------------- + // Convenience wrappers — print response to stdout + // ----------------------------------------------------------------------- + + pub async fn run_get(&self, path: &str) -> Result<(), CliError> { + let resp = self.request(reqwest::Method::GET, path, None).await?; + println!("{resp}"); + Ok(()) + } + + pub async fn run_post(&self, path: &str, body: &serde_json::Value) -> Result<(), CliError> { + let resp = self + .request(reqwest::Method::POST, path, Some(body)) + .await?; + println!("{resp}"); + Ok(()) + } + + pub async fn run_put(&self, path: &str, body: &serde_json::Value) -> Result<(), CliError> { + let resp = self.request(reqwest::Method::PUT, path, Some(body)).await?; + println!("{resp}"); + Ok(()) + } + + pub async fn run_delete(&self, path: &str) -> Result<(), CliError> { + let resp = self.request(reqwest::Method::DELETE, path, None).await?; + println!("{resp}"); + Ok(()) + } + + // For commands that need the raw response string (e.g. get_users multi-dispatch) + pub async fn get_raw(&self, path: &str) -> Result { + self.request(reqwest::Method::GET, path, None).await + } + + pub async fn post_raw(&self, path: &str, body: &serde_json::Value) -> Result { + self.request(reqwest::Method::POST, path, Some(body)).await + } +} + +// --------------------------------------------------------------------------- +// URL normalization +// --------------------------------------------------------------------------- + +/// Normalize a relay URL: ws:// → http://, wss:// → https://, strip trailing slash. +/// SPROUT_RELAY_URL may be ws/wss (copied from MCP config). +pub fn normalize_relay_url(url: &str) -> String { + url.replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +// --------------------------------------------------------------------------- +// Auto-mint token (NIP-98) +// --------------------------------------------------------------------------- + +/// Mint a short-lived Bearer token using NIP-98 HTTP auth. +/// Called at startup when SPROUT_PRIVATE_KEY is set. +pub async fn auto_mint_token(relay_url: &str, private_key_str: &str) -> Result { + use base64::engine::general_purpose::STANDARD as B64; + use base64::Engine; + use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; + use sha2::{Digest, Sha256}; + + let keys = Keys::parse(private_key_str) + .map_err(|e| CliError::Key(format!("invalid SPROUT_PRIVATE_KEY: {e}")))?; + + let token_url = format!("{}/api/tokens", relay_url); + + // Body bytes for payload hash + let body = serde_json::json!({ + "name": "sprout-cli-auto", + "scopes": [ + "messages:read", "messages:write", + "channels:read", "channels:write", + "users:read", "users:write", + "files:read", "files:write" + ], + "expires_in_days": 1 + }); + let body_bytes = serde_json::to_vec(&body).map_err(|e| CliError::Other(e.to_string()))?; + + // SHA-256 hex of body bytes (NIP-98 payload tag) + let hash = Sha256::digest(&body_bytes); + let sha256_hex = hash + .iter() + .map(|b| format!("{:02x}", b)) + .collect::(); + + // Build NIP-98 event + let event = EventBuilder::new( + Kind::HttpAuth, + "", + [ + Tag::parse(&["u", &token_url]).map_err(|e| CliError::Key(format!("tag error: {e}")))?, + Tag::parse(&["method", "POST"]) + .map_err(|e| CliError::Key(format!("tag error: {e}")))?, + Tag::parse(&["payload", &sha256_hex]) + .map_err(|e| CliError::Key(format!("tag error: {e}")))?, + ], + ) + .sign_with_keys(&keys) + .map_err(|e| CliError::Key(format!("signing failed: {e}")))?; + + let auth_header = format!("Nostr {}", B64.encode(event.as_json())); + + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| CliError::Other(e.to_string()))?; + + let resp = http + .post(&token_url) + .header("Authorization", auth_header) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::Auth(format!( + "auto-mint failed ({status}): {body}" + ))); + } + + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| CliError::Other(format!("invalid auto-mint response: {e}")))?; + + json.get("token") + .and_then(|t| t.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| CliError::Other("auto-mint response missing 'token' field".into())) +} diff --git a/crates/sprout-cli/src/commands/auth.rs b/crates/sprout-cli/src/commands/auth.rs new file mode 100644 index 00000000..ca4fb71e --- /dev/null +++ b/crates/sprout-cli/src/commands/auth.rs @@ -0,0 +1,116 @@ +use crate::error::CliError; +use crate::validate::validate_uuid; +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; +use sha2::{Digest, Sha256}; + +/// Mint a long-lived (7-day) API token via NIP-98 and print it to stdout. +/// +/// `private_key` is the --private-key CLI flag value; falls back to SPROUT_PRIVATE_KEY env var. +/// Caller stores the printed token (e.g. `export SPROUT_API_TOKEN=$(sprout auth)`). +pub async fn cmd_auth(relay_url: &str, private_key: Option<&str>) -> Result<(), CliError> { + let env_key; + let private_key_str = match private_key { + Some(k) => k, + None => { + env_key = std::env::var("SPROUT_PRIVATE_KEY").map_err(|_| { + CliError::Auth( + "SPROUT_PRIVATE_KEY not set (use --private-key or set env var)".into(), + ) + })?; + &env_key + } + }; + + let keys = Keys::parse(private_key_str) + .map_err(|e| CliError::Key(format!("invalid private key: {e}")))?; + + let token_url = format!("{}/api/tokens", relay_url); + + // Request body — long-lived token, 7 days + let body = serde_json::json!({ + "name": "sprout-cli", + "scopes": [ + "messages:read", "messages:write", + "channels:read", "channels:write", + "users:read", "users:write", + "files:read", "files:write" + ], + "expires_in_days": 7 + }); + let body_bytes = serde_json::to_vec(&body).map_err(|e| CliError::Other(e.to_string()))?; + + // SHA-256 hex of body bytes (NIP-98 payload tag) + let hash = Sha256::digest(&body_bytes); + let sha256_hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect(); + + // Build NIP-98 event + let event = EventBuilder::new( + Kind::HttpAuth, + "", + [ + Tag::parse(&["u", &token_url]) + .map_err(|e| CliError::Key(format!("tag build failed: {e}")))?, + Tag::parse(&["method", "POST"]) + .map_err(|e| CliError::Key(format!("tag build failed: {e}")))?, + Tag::parse(&["payload", &sha256_hex]) + .map_err(|e| CliError::Key(format!("tag build failed: {e}")))?, + ], + ) + .sign_with_keys(&keys) + .map_err(|e| CliError::Key(format!("signing failed: {e}")))?; + + let auth_header = format!("Nostr {}", B64.encode(event.as_json())); + + // POST /api/tokens + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| CliError::Other(e.to_string()))?; + + let resp = http + .post(&token_url) + .header("Authorization", auth_header) + .json(&body) + .send() + .await + .map_err(CliError::Network)?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body_text = resp.text().await.unwrap_or_default(); + return Err(CliError::Auth(format!( + "token mint failed ({status}): {body_text}" + ))); + } + + let resp_body: serde_json::Value = resp + .json() + .await + .map_err(|e| CliError::Other(format!("invalid response: {e}")))?; + + let token = resp_body + .get("token") + .and_then(|t| t.as_str()) + .ok_or_else(|| CliError::Other("response missing 'token' field".into()))?; + + println!("{token}"); + Ok(()) +} + +pub async fn cmd_list_tokens(client: &crate::client::SproutClient) -> Result<(), CliError> { + client.run_get("/api/tokens").await +} + +pub async fn cmd_delete_token( + client: &crate::client::SproutClient, + id: &str, +) -> Result<(), CliError> { + validate_uuid(id)?; + client.run_delete(&format!("/api/tokens/{}", id)).await +} + +pub async fn cmd_delete_all_tokens(client: &crate::client::SproutClient) -> Result<(), CliError> { + client.run_delete("/api/tokens").await +} diff --git a/crates/sprout-cli/src/commands/channels.rs b/crates/sprout-cli/src/commands/channels.rs new file mode 100644 index 00000000..83a5ad16 --- /dev/null +++ b/crates/sprout-cli/src/commands/channels.rs @@ -0,0 +1,228 @@ +use crate::client::SproutClient; +use crate::error::CliError; +use crate::validate::{percent_encode, read_or_stdin, validate_hex64, validate_uuid}; + +pub async fn cmd_list_channels( + client: &SproutClient, + visibility: Option<&str>, + member: Option, +) -> Result<(), CliError> { + let mut path = "/api/channels".to_string(); + let mut sep = '?'; + if let Some(v) = visibility { + path.push_str(&format!("{}visibility={}", sep, percent_encode(v))); + sep = '&'; + } + if let Some(m) = member { + path.push_str(&format!("{}member={}", sep, m)); + } + client.run_get(&path).await +} + +pub async fn cmd_get_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { + validate_uuid(channel_id)?; + client + .run_get(&format!("/api/channels/{}", channel_id)) + .await +} + +pub async fn cmd_create_channel( + client: &SproutClient, + name: &str, + channel_type: &str, + visibility: &str, + description: Option<&str>, +) -> Result<(), CliError> { + match channel_type { + "stream" | "forum" => {} + _ => { + return Err(CliError::Usage(format!( + "--type must be 'stream' or 'forum' (got: {channel_type})" + ))) + } + } + match visibility { + "open" | "private" => {} + _ => { + return Err(CliError::Usage(format!( + "--visibility must be 'open' or 'private' (got: {visibility})" + ))) + } + } + let mut body = serde_json::json!({ + "name": name, + "channel_type": channel_type, + "visibility": visibility, + }); + if let Some(d) = description { + body["description"] = d.into(); + } + client.run_post("/api/channels", &body).await +} + +pub async fn cmd_update_channel( + client: &SproutClient, + channel_id: &str, + name: Option<&str>, + description: Option<&str>, +) -> Result<(), CliError> { + if name.is_none() && description.is_none() { + return Err(CliError::Usage( + "at least one field required (--name, --description)".into(), + )); + } + validate_uuid(channel_id)?; + let mut body = serde_json::json!({}); + if let Some(n) = name { + body["name"] = n.into(); + } + if let Some(d) = description { + body["description"] = d.into(); + } + client + .run_put(&format!("/api/channels/{}", channel_id), &body) + .await +} + +pub async fn cmd_set_channel_topic( + client: &SproutClient, + channel_id: &str, + topic: &str, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + client + .run_put( + &format!("/api/channels/{}/topic", channel_id), + &serde_json::json!({ "topic": topic }), + ) + .await +} + +pub async fn cmd_set_channel_purpose( + client: &SproutClient, + channel_id: &str, + purpose: &str, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + client + .run_put( + &format!("/api/channels/{}/purpose", channel_id), + &serde_json::json!({ "purpose": purpose }), + ) + .await +} + +pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { + validate_uuid(channel_id)?; + client + .run_post( + &format!("/api/channels/{}/join", channel_id), + &serde_json::json!({}), + ) + .await +} + +pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { + validate_uuid(channel_id)?; + client + .run_post( + &format!("/api/channels/{}/leave", channel_id), + &serde_json::json!({}), + ) + .await +} + +pub async fn cmd_archive_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { + validate_uuid(channel_id)?; + client + .run_post( + &format!("/api/channels/{}/archive", channel_id), + &serde_json::json!({}), + ) + .await +} + +pub async fn cmd_unarchive_channel( + client: &SproutClient, + channel_id: &str, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + client + .run_post( + &format!("/api/channels/{}/unarchive", channel_id), + &serde_json::json!({}), + ) + .await +} + +pub async fn cmd_delete_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { + validate_uuid(channel_id)?; + client + .run_delete(&format!("/api/channels/{}", channel_id)) + .await +} + +pub async fn cmd_list_channel_members( + client: &SproutClient, + channel_id: &str, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + client + .run_get(&format!("/api/channels/{}/members", channel_id)) + .await +} + +pub async fn cmd_add_channel_member( + client: &SproutClient, + channel_id: &str, + pubkey: &str, + role: Option<&str>, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + validate_hex64(pubkey)?; + let mut body = serde_json::json!({ "pubkeys": [pubkey] }); + if let Some(r) = role { + body["role"] = r.into(); + } + client + .run_post(&format!("/api/channels/{}/members", channel_id), &body) + .await +} + +pub async fn cmd_remove_channel_member( + client: &SproutClient, + channel_id: &str, + pubkey: &str, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + validate_hex64(pubkey)?; + client + .run_delete(&format!( + "/api/channels/{}/members/{}", + channel_id, + percent_encode(pubkey), + )) + .await +} + +pub async fn cmd_get_canvas(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { + validate_uuid(channel_id)?; + client + .run_get(&format!("/api/channels/{}/canvas", channel_id)) + .await +} + +pub async fn cmd_set_canvas( + client: &SproutClient, + channel_id: &str, + content: &str, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + let content = read_or_stdin(content)?; + client + .run_put( + &format!("/api/channels/{}/canvas", channel_id), + &serde_json::json!({ "content": content }), + ) + .await +} diff --git a/crates/sprout-cli/src/commands/dms.rs b/crates/sprout-cli/src/commands/dms.rs new file mode 100644 index 00000000..a84d550b --- /dev/null +++ b/crates/sprout-cli/src/commands/dms.rs @@ -0,0 +1,47 @@ +use crate::client::SproutClient; +use crate::error::CliError; +use crate::validate::{percent_encode, validate_hex64, validate_uuid}; + +pub async fn cmd_list_dms( + client: &SproutClient, + cursor: Option<&str>, + limit: Option, +) -> Result<(), CliError> { + let mut path = "/api/dms".to_string(); + let mut sep = '?'; + if let Some(c) = cursor { + path.push_str(&format!("{}cursor={}", sep, percent_encode(c))); + sep = '&'; + } + if let Some(l) = limit { + path.push_str(&format!("{}limit={}", sep, l)); + } + client.run_get(&path).await +} + +pub async fn cmd_open_dm(client: &SproutClient, pubkeys: &[String]) -> Result<(), CliError> { + if pubkeys.is_empty() || pubkeys.len() > 8 { + return Err(CliError::Usage("--pubkey: must provide 1–8 pubkeys".into())); + } + for pk in pubkeys { + validate_hex64(pk)?; + } + client + .run_post("/api/dms", &serde_json::json!({ "pubkeys": pubkeys })) + .await +} + +pub async fn cmd_add_dm_member( + client: &SproutClient, + channel_id: &str, + pubkey: &str, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + validate_hex64(pubkey)?; + client + .run_post( + &format!("/api/dms/{}/members", channel_id), + &serde_json::json!({ "pubkeys": [pubkey] }), + ) + .await +} diff --git a/crates/sprout-cli/src/commands/feed.rs b/crates/sprout-cli/src/commands/feed.rs new file mode 100644 index 00000000..db0f6d5f --- /dev/null +++ b/crates/sprout-cli/src/commands/feed.rs @@ -0,0 +1,20 @@ +use crate::client::SproutClient; +use crate::error::CliError; +use crate::validate::percent_encode; + +pub async fn cmd_get_feed( + client: &SproutClient, + since: Option, + limit: Option, + types: Option<&str>, +) -> Result<(), CliError> { + let limit = limit.unwrap_or(20).min(50); + let mut path = format!("/api/feed?limit={}", limit); + if let Some(s) = since { + path.push_str(&format!("&since={s}")); + } + if let Some(t) = types { + path.push_str(&format!("&types={}", percent_encode(t))); + } + client.run_get(&path).await +} diff --git a/crates/sprout-cli/src/commands/messages.rs b/crates/sprout-cli/src/commands/messages.rs new file mode 100644 index 00000000..3decf639 --- /dev/null +++ b/crates/sprout-cli/src/commands/messages.rs @@ -0,0 +1,240 @@ +use crate::client::SproutClient; +use crate::error::CliError; +use crate::validate::{ + infer_language, normalize_mention_pubkeys, percent_encode, read_or_stdin, truncate_diff, + validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, +}; + +pub async fn cmd_get_messages( + client: &SproutClient, + channel_id: &str, + limit: Option, + before: Option, + kinds: Option<&str>, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + let limit = limit.unwrap_or(50).min(200); + let mut path = format!("/api/channels/{}/messages?limit={}", channel_id, limit); + if let Some(b) = before { + path.push_str(&format!("&before={b}")); + } + if let Some(k) = kinds { + path.push_str(&format!("&kinds={}", percent_encode(k))); + } + client.run_get(&path).await +} + +pub async fn cmd_get_thread( + client: &SproutClient, + channel_id: &str, + event_id: &str, + depth_limit: Option, + limit: Option, + cursor: Option<&str>, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + validate_hex64(event_id)?; + let limit = limit.unwrap_or(100).min(500); + let mut path = format!( + "/api/channels/{}/threads/{}?limit={}", + channel_id, event_id, limit + ); + if let Some(d) = depth_limit { + path.push_str(&format!("&depth_limit={d}")); + } + if let Some(c) = cursor { + path.push_str(&format!("&cursor={}", percent_encode(c))); + } + client.run_get(&path).await +} + +pub async fn cmd_send_message( + client: &SproutClient, + channel_id: &str, + content: &str, + kind: Option, + reply_to: Option<&str>, + broadcast: bool, + mentions: &[String], +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + validate_content_size(content)?; + if let Some(r) = reply_to { + validate_hex64(r)?; + } + for m in mentions { + validate_hex64(m)?; + } + + // Normalize mentions: lowercase, deduplicate, remove sender (unknown in v1) + let normalized_mentions = normalize_mention_pubkeys(mentions, ""); + + let mut body = serde_json::json!({ + "content": content, + "broadcast_to_channel": broadcast, + }); + // kind: u16 flag → u32 in JSON body (all valid Nostr kinds fit in u16) + if let Some(k) = kind { + body["kind"] = (k as u32).into(); + } + if let Some(r) = reply_to { + body["parent_event_id"] = r.into(); + } + if !normalized_mentions.is_empty() { + body["mention_pubkeys"] = normalized_mentions.into(); + } + + client + .run_post(&format!("/api/channels/{}/messages", channel_id), &body) + .await +} + +pub struct SendDiffParams { + pub channel_id: String, + pub diff: String, + pub repo_url: String, + pub commit_sha: String, + pub file_path: Option, + pub parent_commit_sha: Option, + pub source_branch: Option, + pub target_branch: Option, + pub pr_number: Option, + pub language: Option, + pub description: Option, + pub reply_to: Option, +} + +pub async fn cmd_send_diff_message( + client: &SproutClient, + p: SendDiffParams, +) -> Result<(), CliError> { + validate_uuid(&p.channel_id)?; + if let Some(r) = &p.reply_to { + validate_hex64(r)?; + } + + // Branch pairing: both or neither + match (&p.source_branch, &p.target_branch) { + (Some(_), None) | (None, Some(_)) => { + return Err(CliError::Usage( + "--source-branch and --target-branch must both be provided or both omitted".into(), + )); + } + _ => {} + } + + // Read diff from stdin if "--diff -" + let diff_content = read_or_stdin(&p.diff)?; + + // Truncate at 60 KiB hunk boundary + let (diff, truncated) = truncate_diff(&diff_content, MAX_DIFF_BYTES); + + // Language inference: explicit flag wins, then infer from file path + let language = p + .language + .clone() + .or_else(|| p.file_path.as_deref().and_then(infer_language)); + + // NIP-31 alt tag + let alt = match (&p.file_path, &p.description) { + (Some(fp), Some(desc)) => format!("Diff: {} — {}", fp, desc), + (Some(fp), None) => format!("Diff: {}", fp), + _ => "Diff".to_string(), + }; + + let mut body = serde_json::json!({ + "content": diff, + "kind": 40008, + "broadcast_to_channel": false, + "diff_repo_url": p.repo_url, + "diff_commit_sha": p.commit_sha, + "diff_alt": alt, + }); + if truncated { + body["diff_truncated"] = true.into(); + } + if let Some(fp) = &p.file_path { + body["diff_file_path"] = fp.clone().into(); + } + if let Some(pc) = &p.parent_commit_sha { + body["diff_parent_commit_sha"] = pc.clone().into(); + } + if let Some(sb) = &p.source_branch { + body["diff_source_branch"] = sb.clone().into(); + } + if let Some(tb) = &p.target_branch { + body["diff_target_branch"] = tb.clone().into(); + } + if let Some(pr) = p.pr_number { + body["diff_pr_number"] = pr.into(); + } + if let Some(lg) = language { + body["diff_language"] = lg.into(); + } + if let Some(ds) = &p.description { + body["diff_description"] = ds.clone().into(); + } + if let Some(re) = &p.reply_to { + body["parent_event_id"] = re.clone().into(); + } + + client + .run_post(&format!("/api/channels/{}/messages", p.channel_id), &body) + .await +} + +pub async fn cmd_delete_message(client: &SproutClient, event_id: &str) -> Result<(), CliError> { + validate_hex64(event_id)?; + client + .run_delete(&format!("/api/messages/{}", event_id)) + .await +} + +/// Edit a message you previously sent. +pub async fn cmd_edit_message( + client: &SproutClient, + event_id: &str, + content: &str, +) -> Result<(), CliError> { + validate_hex64(event_id)?; + validate_content_size(content)?; + client + .run_put( + &format!("/api/messages/{}", event_id), + &serde_json::json!({ "content": content }), + ) + .await +} + +/// Vote on a forum post or comment. +pub async fn cmd_vote_on_post( + client: &SproutClient, + event_id: &str, + direction: &str, +) -> Result<(), CliError> { + validate_hex64(event_id)?; + match direction { + "up" | "down" => {} + _ => { + return Err(CliError::Usage(format!( + "--direction must be 'up' or 'down' (got: {direction})" + ))) + } + } + client + .run_post( + &format!("/api/messages/{}/votes", event_id), + &serde_json::json!({ "direction": direction }), + ) + .await +} + +pub async fn cmd_search( + client: &SproutClient, + query: &str, + limit: Option, +) -> Result<(), CliError> { + let limit = limit.unwrap_or(20).min(100); + let path = format!("/api/search?q={}&limit={}", percent_encode(query), limit); + client.run_get(&path).await +} diff --git a/crates/sprout-cli/src/commands/mod.rs b/crates/sprout-cli/src/commands/mod.rs new file mode 100644 index 00000000..c56b23f1 --- /dev/null +++ b/crates/sprout-cli/src/commands/mod.rs @@ -0,0 +1,8 @@ +pub mod auth; +pub mod channels; +pub mod dms; +pub mod feed; +pub mod messages; +pub mod reactions; +pub mod users; +pub mod workflows; diff --git a/crates/sprout-cli/src/commands/reactions.rs b/crates/sprout-cli/src/commands/reactions.rs new file mode 100644 index 00000000..dad18574 --- /dev/null +++ b/crates/sprout-cli/src/commands/reactions.rs @@ -0,0 +1,42 @@ +use crate::client::SproutClient; +use crate::error::CliError; +use crate::validate::{percent_encode, validate_hex64}; + +pub async fn cmd_add_reaction( + client: &SproutClient, + event_id: &str, + emoji: &str, +) -> Result<(), CliError> { + validate_hex64(event_id)?; + client + .run_post( + &format!("/api/messages/{}/reactions", percent_encode(event_id)), + &serde_json::json!({ "emoji": emoji }), + ) + .await +} + +pub async fn cmd_remove_reaction( + client: &SproutClient, + event_id: &str, + emoji: &str, +) -> Result<(), CliError> { + validate_hex64(event_id)?; + client + .run_delete(&format!( + "/api/messages/{}/reactions/{}", + percent_encode(event_id), + percent_encode(emoji), + )) + .await +} + +pub async fn cmd_get_reactions(client: &SproutClient, event_id: &str) -> Result<(), CliError> { + validate_hex64(event_id)?; + client + .run_get(&format!( + "/api/messages/{}/reactions", + percent_encode(event_id), + )) + .await +} diff --git a/crates/sprout-cli/src/commands/users.rs b/crates/sprout-cli/src/commands/users.rs new file mode 100644 index 00000000..7ab43f18 --- /dev/null +++ b/crates/sprout-cli/src/commands/users.rs @@ -0,0 +1,111 @@ +use crate::client::SproutClient; +use crate::error::CliError; +use crate::validate::{percent_encode, validate_hex64}; + +/// 3-way dispatch based on pubkey count: +/// 0 pubkeys → GET /api/users/me/profile +/// 1 pubkey → GET /api/users/{pk}/profile +/// 2+ pubkeys → POST /api/users/batch +pub async fn cmd_get_users(client: &SproutClient, pubkeys: &[String]) -> Result<(), CliError> { + for pk in pubkeys { + validate_hex64(pk)?; + } + if pubkeys.len() > 200 { + return Err(CliError::Usage("--pubkey: maximum 200 pubkeys".into())); + } + + let resp = match pubkeys.len() { + 0 => client.get_raw("/api/users/me/profile").await?, + 1 => { + client + .get_raw(&format!( + "/api/users/{}/profile", + percent_encode(&pubkeys[0]) + )) + .await? + } + _ => { + client + .post_raw( + "/api/users/batch", + &serde_json::json!({ "pubkeys": pubkeys }), + ) + .await? + } + }; + println!("{resp}"); + Ok(()) +} + +pub async fn cmd_set_profile( + client: &SproutClient, + display_name: Option<&str>, + avatar_url: Option<&str>, + about: Option<&str>, + nip05_handle: Option<&str>, +) -> Result<(), CliError> { + if display_name.is_none() && avatar_url.is_none() && about.is_none() && nip05_handle.is_none() { + return Err(CliError::Usage( + "at least one field required (--name, --avatar, --about, --nip05)".into(), + )); + } + let mut body = serde_json::json!({}); + if let Some(n) = display_name { + body["display_name"] = n.into(); + } + if let Some(a) = avatar_url { + body["avatar_url"] = a.into(); + } + if let Some(a) = about { + body["about"] = a.into(); + } + if let Some(h) = nip05_handle { + body["nip05_handle"] = h.into(); + } + client.run_put("/api/users/me/profile", &body).await +} + +pub async fn cmd_get_presence(client: &SproutClient, pubkeys_csv: &str) -> Result<(), CliError> { + for pk in pubkeys_csv.split(',') { + let pk = pk.trim(); + if !pk.is_empty() { + validate_hex64(pk)?; + } + } + let path = format!("/api/presence?pubkeys={}", percent_encode(pubkeys_csv)); + client.run_get(&path).await +} + +pub async fn cmd_set_presence(client: &SproutClient, status: &str) -> Result<(), CliError> { + match status { + "online" | "away" | "offline" => {} + _ => { + return Err(CliError::Usage(format!( + "--status must be one of: online, away, offline (got: {status})" + ))) + } + } + client + .run_put("/api/presence", &serde_json::json!({ "status": status })) + .await +} + +pub async fn cmd_set_channel_add_policy( + client: &SproutClient, + policy: &str, +) -> Result<(), CliError> { + match policy { + "anyone" | "owner_only" | "nobody" => {} + _ => { + return Err(CliError::Usage(format!( + "--policy must be one of: anyone, owner_only, nobody (got: {policy})" + ))) + } + } + client + .run_put( + "/api/users/me/channel-add-policy", + &serde_json::json!({ "channel_add_policy": policy }), + ) + .await +} diff --git a/crates/sprout-cli/src/commands/workflows.rs b/crates/sprout-cli/src/commands/workflows.rs new file mode 100644 index 00000000..e8ce542b --- /dev/null +++ b/crates/sprout-cli/src/commands/workflows.rs @@ -0,0 +1,99 @@ +use crate::client::SproutClient; +use crate::error::CliError; +use crate::validate::{read_or_stdin, validate_uuid}; + +pub async fn cmd_list_workflows(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { + validate_uuid(channel_id)?; + client + .run_get(&format!("/api/channels/{}/workflows", channel_id)) + .await +} + +pub async fn cmd_create_workflow( + client: &SproutClient, + channel_id: &str, + yaml: &str, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + let yaml_definition = read_or_stdin(yaml)?; + client + .run_post( + &format!("/api/channels/{}/workflows", channel_id), + &serde_json::json!({ "yaml_definition": yaml_definition }), + ) + .await +} + +pub async fn cmd_update_workflow( + client: &SproutClient, + workflow_id: &str, + yaml: &str, +) -> Result<(), CliError> { + validate_uuid(workflow_id)?; + let yaml_definition = read_or_stdin(yaml)?; + client + .run_put( + &format!("/api/workflows/{}", workflow_id), + &serde_json::json!({ "yaml_definition": yaml_definition }), + ) + .await +} + +pub async fn cmd_delete_workflow(client: &SproutClient, workflow_id: &str) -> Result<(), CliError> { + validate_uuid(workflow_id)?; + client + .run_delete(&format!("/api/workflows/{}", workflow_id)) + .await +} + +pub async fn cmd_trigger_workflow( + client: &SproutClient, + workflow_id: &str, +) -> Result<(), CliError> { + validate_uuid(workflow_id)?; + client + .run_post( + &format!("/api/workflows/{}/trigger", workflow_id), + &serde_json::json!({}), + ) + .await +} + +pub async fn cmd_get_workflow_runs( + client: &SproutClient, + workflow_id: &str, + limit: Option, +) -> Result<(), CliError> { + validate_uuid(workflow_id)?; + let limit = limit.unwrap_or(20).min(100); + let path = format!("/api/workflows/{}/runs?limit={}", workflow_id, limit); + client.run_get(&path).await +} + +pub async fn cmd_get_workflow(client: &SproutClient, workflow_id: &str) -> Result<(), CliError> { + validate_uuid(workflow_id)?; + client + .run_get(&format!("/api/workflows/{}", workflow_id)) + .await +} + +/// Route is /grant or /deny based on the `approved` flag. +pub async fn cmd_approve_step( + client: &SproutClient, + approval_token: &str, + approved: bool, + note: Option<&str>, +) -> Result<(), CliError> { + validate_uuid(approval_token)?; + let route = if approved { "grant" } else { "deny" }; + let mut body = serde_json::json!({}); + if let Some(n) = note { + body["note"] = n.into(); + } + client + .run_post( + &format!("/api/approvals/{}/{}", approval_token, route), + &body, + ) + .await +} diff --git a/crates/sprout-cli/src/error.rs b/crates/sprout-cli/src/error.rs new file mode 100644 index 00000000..6e99e4bc --- /dev/null +++ b/crates/sprout-cli/src/error.rs @@ -0,0 +1,71 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CliError { + /// Invalid argument or flag value — user error + #[error("{0}")] + Usage(String), + + /// Relay returned a non-2xx response + #[error("relay error {status}: {body}")] + Relay { status: u16, body: String }, + + /// Network-level failure (connect, timeout, DNS) + #[error("network error: {0}")] + Network(#[from] reqwest::Error), + + /// Auth missing or rejected (401/403) + #[error("auth error: {0}")] + Auth(String), + + /// Nostr key error (NIP-98 signing in `sprout auth`) + #[error("key error: {0}")] + Key(String), + + /// Catch-all for unexpected failures + #[error("{0}")] + Other(String), +} + +/// Map CliError to process exit code. +/// 0=success (not an error), 1=user, 2=network/relay, 3=auth, 4=other +pub fn exit_code(e: &CliError) -> i32 { + match e { + CliError::Usage(_) => 1, + CliError::Relay { status, .. } => { + if *status == 401 || *status == 403 { + 3 + } else { + 2 + } + } + CliError::Network(_) => 2, + CliError::Auth(_) => 3, + CliError::Key(_) => 3, + CliError::Other(_) => 4, + } +} + +/// Serialize error to JSON and write to stderr. +/// Format: {"error": "", "message": ""} +pub fn print_error(e: &CliError) { + let category = match e { + CliError::Usage(_) => "user_error", + CliError::Relay { status, .. } => { + if *status == 401 || *status == 403 { + "auth_error" + } else { + "relay_error" + } + } + CliError::Network(_) => "network_error", + CliError::Auth(_) => "auth_error", + CliError::Key(_) => "key_error", + CliError::Other(_) => "error", + }; + let obj = serde_json::json!({ + "error": category, + "message": e.to_string(), + }); + eprintln!("{}", obj); +} diff --git a/crates/sprout-cli/src/main.rs b/crates/sprout-cli/src/main.rs new file mode 100644 index 00000000..4f56675b --- /dev/null +++ b/crates/sprout-cli/src/main.rs @@ -0,0 +1,835 @@ +mod client; +mod commands; +mod error; +mod validate; + +use clap::{Parser, Subcommand}; +use client::{Auth, SproutClient}; +use error::CliError; + +// --------------------------------------------------------------------------- +// Top-level CLI +// --------------------------------------------------------------------------- + +#[derive(Parser)] +#[command(name = "sprout", about = "Sprout CLI — interact with a Sprout relay")] +struct Cli { + #[arg( + long, + env = "SPROUT_RELAY_URL", + default_value = "http://localhost:3000" + )] + relay: String, + + #[arg(long, env = "SPROUT_API_TOKEN")] + token: Option, + + #[arg(long, env = "SPROUT_PRIVATE_KEY", hide = true)] + private_key: Option, + + #[arg(long, env = "SPROUT_PUBKEY")] + pubkey: Option, + + #[command(subcommand)] + command: Cmd, +} + +// --------------------------------------------------------------------------- +// Subcommands +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +enum Cmd { + // ---- Messages ---------------------------------------------------------- + /// Send a message to a channel + SendMessage { + #[arg(long)] + channel: String, + #[arg(long)] + content: String, + #[arg(long)] + kind: Option, + #[arg(long)] + reply_to: Option, + #[arg(long, default_value_t = false)] + broadcast: bool, + #[arg(long = "mention")] + mentions: Vec, + }, + /// Send a diff/code-review message + SendDiffMessage { + #[arg(long)] + channel: String, + #[arg(long)] + diff: String, + #[arg(long)] + repo: String, + #[arg(long)] + commit: String, + #[arg(long)] + file: Option, + #[arg(long)] + parent_commit: Option, + #[arg(long)] + source_branch: Option, + #[arg(long)] + target_branch: Option, + #[arg(long)] + pr: Option, + #[arg(long)] + lang: Option, + #[arg(long)] + description: Option, + #[arg(long)] + reply_to: Option, + }, + /// Delete a message by event ID + DeleteMessage { + #[arg(long)] + event: String, + }, + /// Get messages from a channel + GetMessages { + #[arg(long)] + channel: String, + #[arg(long)] + limit: Option, + #[arg(long)] + before: Option, + #[arg(long)] + kinds: Option, + }, + /// Get a message thread + GetThread { + #[arg(long)] + channel: String, + #[arg(long)] + event: String, + #[arg(long)] + depth_limit: Option, + #[arg(long)] + limit: Option, + #[arg(long)] + cursor: Option, + }, + /// Search messages + Search { + #[arg(long)] + query: String, + #[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 { + /// Event ID of the post to vote on (64-char hex) + #[arg(long)] + event: String, + /// Vote direction: "up" or "down" + #[arg(long)] + direction: String, + }, + + // ---- Channels ---------------------------------------------------------- + /// List channels + ListChannels { + #[arg(long)] + visibility: Option, + #[arg(long, default_value_t = false)] + member: bool, + }, + /// Get a channel by ID + GetChannel { + #[arg(long)] + channel: String, + }, + /// Create a new channel + CreateChannel { + #[arg(long)] + name: String, + #[arg(long = "type")] + channel_type: String, + #[arg(long)] + visibility: String, + #[arg(long)] + description: Option, + }, + /// Update a channel's name or description + UpdateChannel { + #[arg(long)] + channel: String, + #[arg(long)] + name: Option, + #[arg(long)] + description: Option, + }, + /// Set a channel's topic + SetChannelTopic { + #[arg(long)] + channel: String, + #[arg(long)] + topic: String, + }, + /// Set a channel's purpose + SetChannelPurpose { + #[arg(long)] + channel: String, + #[arg(long)] + purpose: String, + }, + /// Join a channel + JoinChannel { + #[arg(long)] + channel: String, + }, + /// Leave a channel + LeaveChannel { + #[arg(long)] + channel: String, + }, + /// Archive a channel + ArchiveChannel { + #[arg(long)] + channel: String, + }, + /// Unarchive a channel + UnarchiveChannel { + #[arg(long)] + channel: String, + }, + /// Delete a channel + DeleteChannel { + #[arg(long)] + channel: String, + }, + /// List channel members + ListChannelMembers { + #[arg(long)] + channel: String, + }, + /// Add a member to a channel + AddChannelMember { + #[arg(long)] + channel: String, + #[arg(long)] + pubkey: String, + #[arg(long)] + role: Option, + }, + /// Remove a member from a channel + RemoveChannelMember { + #[arg(long)] + channel: String, + #[arg(long)] + pubkey: String, + }, + /// Get a channel's canvas + GetCanvas { + #[arg(long)] + channel: String, + }, + /// Set a channel's canvas content + SetCanvas { + #[arg(long)] + channel: String, + #[arg(long)] + content: String, + }, + + // ---- Reactions --------------------------------------------------------- + /// Add a reaction to a message + AddReaction { + #[arg(long)] + event: String, + #[arg(long)] + emoji: String, + }, + /// Remove a reaction from a message + RemoveReaction { + #[arg(long)] + event: String, + #[arg(long)] + emoji: String, + }, + /// Get reactions on a message + GetReactions { + #[arg(long)] + event: String, + }, + + // ---- DMs --------------------------------------------------------------- + /// List DM conversations + ListDms { + #[arg(long)] + cursor: Option, + #[arg(long)] + limit: Option, + }, + /// Open a DM with one or more users (1–8 pubkeys) + OpenDm { + #[arg(long = "pubkey")] + pubkeys: Vec, + }, + /// Add a member to a DM group + AddDmMember { + #[arg(long)] + channel: String, + #[arg(long)] + pubkey: String, + }, + + // ---- Users ------------------------------------------------------------- + /// Get user profiles (0 = self, 1 = single, 2+ = batch) + GetUsers { + #[arg(long = "pubkey")] + pubkeys: Vec, + }, + /// Update your profile + SetProfile { + #[arg(long)] + name: Option, + #[arg(long)] + avatar: Option, + #[arg(long)] + about: Option, + #[arg(long)] + nip05: Option, + }, + /// Get presence status for users (comma-separated pubkeys) + GetPresence { + #[arg(long)] + pubkeys: String, + }, + /// Set your presence status + SetPresence { + #[arg(long)] + status: String, + }, + /// Set who can add you to channels + SetChannelAddPolicy { + #[arg(long)] + policy: String, + }, + + // ---- Workflows --------------------------------------------------------- + /// List workflows in a channel + ListWorkflows { + #[arg(long)] + channel: String, + }, + /// Create a workflow in a channel + CreateWorkflow { + #[arg(long)] + channel: String, + #[arg(long)] + yaml: String, + }, + /// Update a workflow + UpdateWorkflow { + #[arg(long)] + workflow: String, + #[arg(long)] + yaml: String, + }, + /// Delete a workflow + DeleteWorkflow { + #[arg(long)] + workflow: String, + }, + /// Trigger a workflow manually + TriggerWorkflow { + #[arg(long)] + workflow: String, + }, + /// Get workflow run history + GetWorkflowRuns { + #[arg(long)] + workflow: String, + #[arg(long)] + limit: Option, + }, + /// Get a workflow definition + GetWorkflow { + #[arg(long)] + workflow: String, + }, + /// Approve or deny a workflow approval step + ApproveStep { + #[arg(long)] + token: String, + /// Whether to approve: "true" or "false" + #[arg(long)] + approved: String, + #[arg(long)] + note: Option, + }, + + // ---- Feed -------------------------------------------------------------- + /// Get your activity feed + GetFeed { + #[arg(long)] + since: Option, + #[arg(long)] + limit: Option, + #[arg(long)] + types: Option, + }, + + // ---- Auth & Tokens ----------------------------------------------------- + /// Mint a long-lived API token (prints token to stdout) + Auth, + /// List your API tokens + ListTokens, + /// Delete an API token by ID + DeleteToken { + #[arg(long)] + id: String, + }, + /// Delete all your API tokens + DeleteAllTokens, +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() { + let cli = match Cli::try_parse() { + Ok(cli) => cli, + Err(e) => { + if e.use_stderr() { + error::print_error(&CliError::Usage(e.to_string())); + std::process::exit(1); + } else { + // --help and --version: print normally (intentional human output) + let _ = e.print(); + std::process::exit(0); + } + } + }; + match run(cli).await { + Ok(()) => {} + Err(e) => { + error::print_error(&e); + std::process::exit(error::exit_code(&e)); + } + } +} + +/// Parse a string flag that must be "true" or "false". +fn parse_bool_flag(flag_name: &str, value: &str) -> Result { + match value { + "true" => Ok(true), + "false" => Ok(false), + other => Err(CliError::Usage(format!( + "{flag_name} must be 'true' or 'false' (got: {other})" + ))), + } +} + +async fn run(cli: Cli) -> Result<(), CliError> { + let relay_url = client::normalize_relay_url(&cli.relay); + + // Auth command is special — runs before SproutClient creation. + // Passes --private-key flag; cmd_auth falls back to SPROUT_PRIVATE_KEY env var. + if let Cmd::Auth = &cli.command { + return commands::auth::cmd_auth(&relay_url, cli.private_key.as_deref()).await; + } + + // Auth resolution: token > private_key (auto-mint) > pubkey > error + let auth = if let Some(token) = cli.token { + Auth::Bearer(token) + } else if let Some(key) = cli.private_key { + let minted = client::auto_mint_token(&relay_url, &key).await?; + Auth::Bearer(minted) + } else if let Some(pk) = cli.pubkey { + Auth::DevMode(pk) + } else { + return Err(CliError::Auth( + "Set SPROUT_API_TOKEN, SPROUT_PRIVATE_KEY, or SPROUT_PUBKEY".into(), + )); + }; + + let client = SproutClient::new(relay_url, auth)?; + + match cli.command { + // ---- Messages ------------------------------------------------------ + Cmd::SendMessage { + channel, + content, + kind, + reply_to, + broadcast, + mentions, + } => { + commands::messages::cmd_send_message( + &client, + &channel, + &content, + kind, + reply_to.as_deref(), + broadcast, + &mentions, + ) + .await + } + Cmd::SendDiffMessage { + channel, + diff, + repo, + commit, + file, + parent_commit, + source_branch, + target_branch, + pr, + lang, + description, + reply_to, + } => { + commands::messages::cmd_send_diff_message( + &client, + commands::messages::SendDiffParams { + channel_id: channel, + diff, + repo_url: repo, + commit_sha: commit, + file_path: file, + parent_commit_sha: parent_commit, + source_branch, + target_branch, + pr_number: pr, + language: lang, + description, + reply_to, + }, + ) + .await + } + Cmd::DeleteMessage { event } => { + commands::messages::cmd_delete_message(&client, &event).await + } + Cmd::GetMessages { + channel, + limit, + before, + kinds, + } => { + commands::messages::cmd_get_messages(&client, &channel, limit, before, kinds.as_deref()) + .await + } + Cmd::GetThread { + channel, + event, + depth_limit, + limit, + cursor, + } => { + commands::messages::cmd_get_thread( + &client, + &channel, + &event, + depth_limit, + limit, + cursor.as_deref(), + ) + .await + } + Cmd::Search { query, limit } => { + commands::messages::cmd_search(&client, &query, limit).await + } + Cmd::EditMessage { event, content } => { + commands::messages::cmd_edit_message(&client, &event, &content).await + } + Cmd::VoteOnPost { event, direction } => { + commands::messages::cmd_vote_on_post(&client, &event, &direction).await + } + + // ---- Channels ------------------------------------------------------ + Cmd::ListChannels { visibility, member } => { + commands::channels::cmd_list_channels(&client, visibility.as_deref(), Some(member)) + .await + } + Cmd::GetChannel { channel } => commands::channels::cmd_get_channel(&client, &channel).await, + Cmd::CreateChannel { + name, + channel_type, + visibility, + description, + } => { + commands::channels::cmd_create_channel( + &client, + &name, + &channel_type, + &visibility, + description.as_deref(), + ) + .await + } + Cmd::UpdateChannel { + channel, + name, + description, + } => { + commands::channels::cmd_update_channel( + &client, + &channel, + name.as_deref(), + description.as_deref(), + ) + .await + } + Cmd::SetChannelTopic { channel, topic } => { + commands::channels::cmd_set_channel_topic(&client, &channel, &topic).await + } + Cmd::SetChannelPurpose { channel, purpose } => { + commands::channels::cmd_set_channel_purpose(&client, &channel, &purpose).await + } + Cmd::JoinChannel { channel } => { + commands::channels::cmd_join_channel(&client, &channel).await + } + Cmd::LeaveChannel { channel } => { + commands::channels::cmd_leave_channel(&client, &channel).await + } + Cmd::ArchiveChannel { channel } => { + commands::channels::cmd_archive_channel(&client, &channel).await + } + Cmd::UnarchiveChannel { channel } => { + commands::channels::cmd_unarchive_channel(&client, &channel).await + } + Cmd::DeleteChannel { channel } => { + commands::channels::cmd_delete_channel(&client, &channel).await + } + Cmd::ListChannelMembers { channel } => { + commands::channels::cmd_list_channel_members(&client, &channel).await + } + Cmd::AddChannelMember { + channel, + pubkey, + role, + } => { + commands::channels::cmd_add_channel_member(&client, &channel, &pubkey, role.as_deref()) + .await + } + Cmd::RemoveChannelMember { channel, pubkey } => { + commands::channels::cmd_remove_channel_member(&client, &channel, &pubkey).await + } + Cmd::GetCanvas { channel } => commands::channels::cmd_get_canvas(&client, &channel).await, + Cmd::SetCanvas { channel, content } => { + commands::channels::cmd_set_canvas(&client, &channel, &content).await + } + + // ---- Reactions ----------------------------------------------------- + Cmd::AddReaction { event, emoji } => { + commands::reactions::cmd_add_reaction(&client, &event, &emoji).await + } + Cmd::RemoveReaction { event, emoji } => { + commands::reactions::cmd_remove_reaction(&client, &event, &emoji).await + } + Cmd::GetReactions { event } => { + commands::reactions::cmd_get_reactions(&client, &event).await + } + + // ---- DMs ----------------------------------------------------------- + Cmd::ListDms { cursor, limit } => { + commands::dms::cmd_list_dms(&client, cursor.as_deref(), limit).await + } + Cmd::OpenDm { pubkeys } => commands::dms::cmd_open_dm(&client, &pubkeys).await, + Cmd::AddDmMember { channel, pubkey } => { + commands::dms::cmd_add_dm_member(&client, &channel, &pubkey).await + } + + // ---- Users --------------------------------------------------------- + Cmd::GetUsers { pubkeys } => commands::users::cmd_get_users(&client, &pubkeys).await, + Cmd::SetProfile { + name, + avatar, + about, + nip05, + } => { + commands::users::cmd_set_profile( + &client, + name.as_deref(), + avatar.as_deref(), + about.as_deref(), + nip05.as_deref(), + ) + .await + } + Cmd::GetPresence { pubkeys } => commands::users::cmd_get_presence(&client, &pubkeys).await, + Cmd::SetPresence { status } => commands::users::cmd_set_presence(&client, &status).await, + Cmd::SetChannelAddPolicy { policy } => { + commands::users::cmd_set_channel_add_policy(&client, &policy).await + } + + // ---- Workflows ----------------------------------------------------- + Cmd::ListWorkflows { channel } => { + commands::workflows::cmd_list_workflows(&client, &channel).await + } + Cmd::CreateWorkflow { channel, yaml } => { + commands::workflows::cmd_create_workflow(&client, &channel, &yaml).await + } + Cmd::UpdateWorkflow { workflow, yaml } => { + commands::workflows::cmd_update_workflow(&client, &workflow, &yaml).await + } + Cmd::DeleteWorkflow { workflow } => { + commands::workflows::cmd_delete_workflow(&client, &workflow).await + } + Cmd::TriggerWorkflow { workflow } => { + commands::workflows::cmd_trigger_workflow(&client, &workflow).await + } + Cmd::GetWorkflowRuns { workflow, limit } => { + commands::workflows::cmd_get_workflow_runs(&client, &workflow, limit).await + } + Cmd::GetWorkflow { workflow } => { + commands::workflows::cmd_get_workflow(&client, &workflow).await + } + Cmd::ApproveStep { + token, + approved, + note, + } => { + let approved = parse_bool_flag("--approved", &approved)?; + commands::workflows::cmd_approve_step(&client, &token, approved, note.as_deref()).await + } + + // ---- Feed ---------------------------------------------------------- + Cmd::GetFeed { + since, + limit, + types, + } => commands::feed::cmd_get_feed(&client, since, limit, types.as_deref()).await, + + // ---- Auth & Tokens ------------------------------------------------- + Cmd::Auth => unreachable!("handled above"), + Cmd::ListTokens => commands::auth::cmd_list_tokens(&client).await, + Cmd::DeleteToken { id } => commands::auth::cmd_delete_token(&client, &id).await, + Cmd::DeleteAllTokens => commands::auth::cmd_delete_all_tokens(&client).await, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + /// Smoke test: CLI definition is valid and parseable. + #[test] + fn cli_definition_is_valid() { + Cli::command().debug_assert(); + } + + /// Regression: parse_bool_flag rejects values other than "true"/"false". + #[test] + fn parse_bool_flag_accepts_true() { + assert!(super::parse_bool_flag("--approved", "true").unwrap()); + } + + #[test] + fn parse_bool_flag_accepts_false() { + assert!(!super::parse_bool_flag("--approved", "false").unwrap()); + } + + #[test] + fn parse_bool_flag_rejects_invalid() { + let err = super::parse_bool_flag("--approved", "maybe").unwrap_err(); + match err { + super::CliError::Usage(msg) => { + assert!(msg.contains("must be 'true' or 'false'"), "got: {msg}"); + assert!(msg.contains("maybe"), "got: {msg}"); + } + other => panic!("expected Usage error, got: {other:?}"), + } + } + + #[test] + fn parse_bool_flag_rejects_empty() { + assert!(super::parse_bool_flag("--approved", "").is_err()); + } + + /// Parity: the CLI exposes exactly the expected 48 commands. + /// If a command is added or removed, this test forces a conscious update. + #[test] + fn command_inventory_is_48() { + let expected: Vec<&str> = vec![ + "add-channel-member", + "add-dm-member", + "add-reaction", + "approve-step", + "archive-channel", + "auth", + "create-channel", + "create-workflow", + "delete-all-tokens", + "delete-channel", + "delete-message", + "delete-token", + "delete-workflow", + "edit-message", + "get-canvas", + "get-channel", + "get-feed", + "get-messages", + "get-presence", + "get-reactions", + "get-thread", + "get-users", + "get-workflow", + "get-workflow-runs", + "join-channel", + "leave-channel", + "list-channel-members", + "list-channels", + "list-dms", + "list-tokens", + "list-workflows", + "open-dm", + "remove-channel-member", + "remove-reaction", + "search", + "send-diff-message", + "send-message", + "set-canvas", + "set-channel-add-policy", + "set-channel-purpose", + "set-channel-topic", + "set-presence", + "set-profile", + "trigger-workflow", + "unarchive-channel", + "update-channel", + "update-workflow", + "vote-on-post", + ]; + + let cmd = Cli::command(); + let mut actual: Vec = cmd + .get_subcommands() + .map(|s| s.get_name().to_string()) + .filter(|n| n != "help") // clap auto-adds "help" + .collect(); + actual.sort(); + + assert_eq!( + actual.len(), + 48, + "Expected 48 commands, got {}. Actual: {:?}", + actual.len(), + actual + ); + assert_eq!(actual, expected, "Command inventory drift detected"); + } +} diff --git a/crates/sprout-cli/src/validate.rs b/crates/sprout-cli/src/validate.rs new file mode 100644 index 00000000..aa392068 --- /dev/null +++ b/crates/sprout-cli/src/validate.rs @@ -0,0 +1,382 @@ +use crate::error::CliError; + +/// Maximum content size in bytes (64 KiB). +pub const MAX_CONTENT_BYTES: usize = 65_536; + +/// Maximum diff size in bytes (60 KiB). +pub const MAX_DIFF_BYTES: usize = 61_440; + +/// Validate UUID string. Returns CliError::Usage on failure. +pub fn validate_uuid(s: &str) -> Result<(), CliError> { + uuid::Uuid::parse_str(s).map_err(|_| CliError::Usage(format!("invalid UUID: {s}")))?; + Ok(()) +} + +/// Validate 64-character lowercase hex string (event_id, pubkey). +pub fn validate_hex64(s: &str) -> Result<(), CliError> { + if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(CliError::Usage(format!( + "must be a 64-character hex string: {s}" + ))); + } + Ok(()) +} + +/// Validate content does not exceed MAX_CONTENT_BYTES (65,536). +pub fn validate_content_size(content: &str) -> Result<(), CliError> { + if content.len() > MAX_CONTENT_BYTES { + return Err(CliError::Usage(format!( + "content exceeds maximum size ({} > {} bytes)", + content.len(), + MAX_CONTENT_BYTES + ))); + } + Ok(()) +} + +/// Percent-encode for URL path segments and query parameter values. +/// Encodes all bytes except RFC 3986 unreserved: A-Z a-z 0-9 - _ . ~ +pub fn percent_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char); + } + _ => { + let hi = char::from_digit((byte >> 4) as u32, 16) + .unwrap() + .to_ascii_uppercase(); + let lo = char::from_digit((byte & 0xf) as u32, 16) + .unwrap() + .to_ascii_uppercase(); + out.push('%'); + out.push(hi); + out.push(lo); + } + } + } + out +} + +/// Truncate diff at hunk boundary within max_bytes (60 KiB for send-diff-message). +/// Returns (truncated_string, was_truncated). +pub fn truncate_diff(diff: &str, max_bytes: usize) -> (String, bool) { + const TRUNCATION_NOTICE: &str = "\n\n[diff truncated — exceeded size limit]"; + if diff.len() <= max_bytes { + return (diff.to_string(), false); + } + let effective_limit = max_bytes.saturating_sub(TRUNCATION_NOTICE.len()); + let utf8_boundary = diff + .char_indices() + .map(|(i, _)| i) + .take_while(|&i| i <= effective_limit) + .last() + .unwrap_or(0); + let safe_prefix = &diff[..utf8_boundary]; + let cut_point = safe_prefix + .rfind("\n@@") + .filter(|&p| p > 0) + .unwrap_or_else(|| safe_prefix.rfind('\n').unwrap_or(utf8_boundary)); + let mut result = diff[..cut_point].to_string(); + result.push_str(TRUNCATION_NOTICE); + (result, true) +} + +/// Infer syntax-highlight language from file extension. +pub fn infer_language(file_path: &str) -> Option { + let ext = file_path.rsplit('.').next()?; + let lang = match ext { + "rs" => "rust", + "ts" | "tsx" => "typescript", + "js" | "jsx" => "javascript", + "py" => "python", + "go" => "go", + "java" => "java", + "rb" => "ruby", + "c" | "h" => "c", + "cpp" | "cc" | "cxx" | "hpp" => "cpp", + "cs" => "csharp", + "swift" => "swift", + "kt" | "kts" => "kotlin", + "scala" => "scala", + "sh" | "bash" | "zsh" => "bash", + "sql" => "sql", + "html" | "htm" => "html", + "css" | "scss" | "sass" => "css", + "json" => "json", + "yaml" | "yml" => "yaml", + "toml" => "toml", + "xml" => "xml", + "md" | "markdown" => "markdown", + "dockerfile" => "dockerfile", + _ => return None, + }; + Some(lang.to_string()) +} + +/// Normalize mention pubkeys: lowercase, deduplicate, remove sender's own pubkey. +pub fn normalize_mention_pubkeys(pubkeys: &[String], sender_pubkey: &str) -> Vec { + let sender = sender_pubkey.to_ascii_lowercase(); + let mut seen = std::collections::HashSet::new(); + pubkeys + .iter() + .map(|pk| pk.to_ascii_lowercase()) + .filter(|pk| pk != &sender) + .filter(|pk| seen.insert(pk.clone())) + .collect() +} + +/// Read content from a string value or stdin if the value is "-". +pub fn read_or_stdin(value: &str) -> Result { + if value == "-" { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .map_err(|e| CliError::Other(format!("failed to read stdin: {e}")))?; + Ok(buf) + } else { + Ok(value.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- validate_uuid --- + + #[test] + fn validate_uuid_valid() { + assert!(validate_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok()); + } + + #[test] + fn validate_uuid_malformed() { + let err = validate_uuid("not-a-uuid").unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + + #[test] + fn validate_uuid_empty() { + let err = validate_uuid("").unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + + // --- validate_hex64 --- + + #[test] + fn validate_hex64_valid() { + let hex = "a".repeat(64); + assert!(validate_hex64(&hex).is_ok()); + } + + #[test] + fn validate_hex64_all_digits() { + let hex = "0123456789abcdef".repeat(4); + assert!(validate_hex64(&hex).is_ok()); + } + + #[test] + fn validate_hex64_too_short() { + let hex = "a".repeat(63); + let err = validate_hex64(&hex).unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + + #[test] + fn validate_hex64_too_long() { + let hex = "a".repeat(65); + let err = validate_hex64(&hex).unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + + #[test] + fn validate_hex64_non_hex_char() { + let mut hex = "a".repeat(63); + hex.push('z'); // 'z' is not a hex digit + let err = validate_hex64(&hex).unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + + // --- validate_content_size --- + + #[test] + fn validate_content_size_at_limit() { + let content = "x".repeat(MAX_CONTENT_BYTES); + assert!(validate_content_size(&content).is_ok()); + } + + #[test] + fn validate_content_size_over_limit() { + let content = "x".repeat(MAX_CONTENT_BYTES + 1); + let err = validate_content_size(&content).unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + + #[test] + fn validate_content_size_empty() { + assert!(validate_content_size("").is_ok()); + } + + // --- percent_encode --- + + #[test] + fn percent_encode_unreserved_unchanged() { + let input = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"; + assert_eq!(percent_encode(input), input); + } + + #[test] + fn percent_encode_space() { + assert_eq!(percent_encode("hello world"), "hello%20world"); + } + + #[test] + fn percent_encode_slash() { + assert_eq!(percent_encode("a/b"), "a%2Fb"); + } + + #[test] + fn percent_encode_unicode_multibyte() { + // '€' is U+20AC, encoded as 3 UTF-8 bytes: 0xE2 0x82 0xAC + assert_eq!(percent_encode("€"), "%E2%82%AC"); + } + + #[test] + fn percent_encode_empty() { + assert_eq!(percent_encode(""), ""); + } + + // --- truncate_diff --- + + #[test] + fn truncate_diff_under_limit_noop() { + let diff = "small diff"; + let (result, was_truncated) = truncate_diff(diff, 1000); + assert_eq!(result, diff); + assert!(!was_truncated); + } + + #[test] + fn truncate_diff_at_limit_noop() { + let diff = "x".repeat(100); + let (result, was_truncated) = truncate_diff(&diff, 100); + assert_eq!(result, diff); + assert!(!was_truncated); + } + + #[test] + fn truncate_diff_cuts_at_hunk_boundary() { + // Build a diff with a @@ hunk marker after the limit + let hunk1 = "@@ -1,3 +1,3 @@\n line1\n line2\n line3\n"; + let hunk2 = "@@ -5,3 +5,3 @@\n line4\n line5\n line6\n"; + let diff = format!("{}{}", hunk1, hunk2); + // Limit to just past hunk1 but before hunk2 completes + let limit = hunk1.len() + 5; + let (result, was_truncated) = truncate_diff(&diff, limit); + assert!(was_truncated); + assert!(result.contains("[diff truncated — exceeded size limit]")); + // Should cut at the \n@@ boundary before hunk2 + assert!(!result.contains("line4")); + } + + #[test] + fn truncate_diff_falls_back_to_newline() { + // No @@ marker — should fall back to last newline + let diff = "line one\nline two\nline three extra long content here"; + let limit = 20; + let (result, was_truncated) = truncate_diff(diff, limit); + assert!(was_truncated); + assert!(result.contains("[diff truncated — exceeded size limit]")); + } + + #[test] + fn truncate_diff_appends_notice() { + let diff = "x".repeat(200); + let (result, was_truncated) = truncate_diff(&diff, 50); + assert!(was_truncated); + assert!(result.ends_with("[diff truncated — exceeded size limit]")); + } + + // --- infer_language --- + + #[test] + fn infer_language_rust() { + assert_eq!(infer_language("main.rs"), Some("rust".to_string())); + } + + #[test] + fn infer_language_tsx() { + assert_eq!(infer_language("App.tsx"), Some("typescript".to_string())); + } + + #[test] + fn infer_language_ts() { + assert_eq!(infer_language("index.ts"), Some("typescript".to_string())); + } + + #[test] + fn infer_language_unknown_ext() { + assert_eq!(infer_language("file.xyz"), None); + } + + #[test] + fn infer_language_no_ext() { + assert_eq!(infer_language("Makefile"), None); + } + + #[test] + fn infer_language_path_with_dirs() { + assert_eq!( + infer_language("src/lib/utils.py"), + Some("python".to_string()) + ); + } + + // --- normalize_mention_pubkeys --- + + #[test] + fn normalize_mention_pubkeys_lowercases() { + // 64 chars total + let pk = "AABBCC".repeat(10) + "aabb"; + assert_eq!(pk.len(), 64); + let result = normalize_mention_pubkeys(std::slice::from_ref(&pk), "sender"); + assert_eq!(result, vec![pk.to_ascii_lowercase()]); + } + + #[test] + fn normalize_mention_pubkeys_removes_sender() { + let sender = "a".repeat(64); + let other = "b".repeat(64); + let pubkeys = vec![sender.clone(), other.clone()]; + let result = normalize_mention_pubkeys(&pubkeys, &sender); + assert_eq!(result, vec![other]); + } + + #[test] + fn normalize_mention_pubkeys_deduplicates() { + let pk = "c".repeat(64); + let pubkeys = vec![pk.clone(), pk.clone(), pk.clone()]; + let result = normalize_mention_pubkeys(&pubkeys, "sender"); + assert_eq!(result.len(), 1); + assert_eq!(result[0], pk); + } + + #[test] + fn normalize_mention_pubkeys_removes_sender_case_insensitive() { + let sender_lower = "d".repeat(64); + let sender_upper = sender_lower.to_ascii_uppercase(); + let other = "e".repeat(64); + let pubkeys = vec![sender_upper, other.clone()]; + let result = normalize_mention_pubkeys(&pubkeys, &sender_lower); + assert_eq!(result, vec![other]); + } + + #[test] + fn normalize_mention_pubkeys_empty_input() { + let result = normalize_mention_pubkeys(&[], "sender"); + assert!(result.is_empty()); + } +} diff --git a/crates/sprout-relay/src/api/messages.rs b/crates/sprout-relay/src/api/messages.rs index af042d28..0f2eb5d5 100644 --- a/crates/sprout-relay/src/api/messages.rs +++ b/crates/sprout-relay/src/api/messages.rs @@ -1269,6 +1269,220 @@ pub async fn get_thread( }))) } +// ── PUT /api/messages/:event_id ─────────────────────────────────────────────── + +/// Request body for editing a message. +#[derive(Debug, Deserialize)] +pub struct EditMessageBody { + /// The new text content for the message. + pub content: String, +} + +/// Edit a channel message by event ID. +/// +/// Produces a kind:40003 (stream message edit) event referencing the original. +/// The caller must be the original author of the message. +/// The event is signed with the relay keypair; the authenticated user is +/// attributed via a `p` tag. +pub async fn edit_message( + State(state): State>, + headers: HeaderMap, + Path(event_id_hex): Path, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let ctx = extract_auth_context(&headers, &state).await?; + sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite) + .map_err(super::scope_error)?; + let pubkey_bytes = ctx.pubkey_bytes.clone(); + + if body.content.trim().is_empty() { + return Err(api_error(StatusCode::BAD_REQUEST, "content is required")); + } + + let event_id_bytes = nostr_hex::decode(&event_id_hex) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event_id hex"))?; + + // Look up the original event. + let stored = state + .db + .get_event_by_id(&event_id_bytes) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))? + .ok_or_else(|| not_found("event not found"))?; + + let channel_id = stored + .channel_id + .ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "event has no channel"))?; + + // Token-level channel restriction check. + check_token_channel_access(&ctx, &channel_id)?; + + // Verify the caller is the original author. + let author_bytes = effective_author(&stored.event, &state.relay_keypair.public_key()); + if author_bytes != pubkey_bytes { + return Err(forbidden("must be the original message author to edit")); + } + + // Verify channel membership. + check_channel_access(&state, channel_id, &pubkey_bytes).await?; + + let channel = state + .db + .get_channel(channel_id) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))?; + if channel.archived_at.is_some() { + return Err(api_error(StatusCode::FORBIDDEN, "channel is archived")); + } + + // Build the kind:40003 edit event. + let user_pubkey_hex = nostr_hex::encode(&pubkey_bytes); + let kind = Kind::from(sprout_core::kind::KIND_STREAM_MESSAGE_EDIT as u16); + + let tags = vec![ + Tag::parse(&["h", &channel_id.to_string()]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + Tag::parse(&["e", &event_id_hex]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + Tag::parse(&["p", &user_pubkey_hex]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + ]; + + let event = EventBuilder::new(kind, &body.content, tags) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| internal_error(&format!("event signing error: {e}")))?; + + let edit_event_id_hex = event.id.to_hex(); + + let (stored_edit, was_inserted) = state + .db + .insert_event(&event, Some(channel_id)) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))?; + + if was_inserted { + let kind_u32 = u32::from(event.kind.as_u16()); + let _ = dispatch_persistent_event(&state, &stored_edit, kind_u32, &user_pubkey_hex).await; + } + + Ok(Json(serde_json::json!({ + "event_id": edit_event_id_hex, + "edited_event_id": event_id_hex, + }))) +} + +// ── POST /api/messages/:event_id/votes ─────────────────────────────────────── + +/// Request body for voting on a forum post or comment. +#[derive(Debug, Deserialize)] +pub struct VoteOnPostBody { + /// Vote direction: "up" or "down". + pub direction: String, +} + +/// Vote on a forum post (kind:45001) or forum comment (kind:45003). +/// +/// Produces a kind:45002 (forum vote) event referencing the target. +/// The caller must be a member of the channel the target event belongs to. +/// The event is signed with the relay keypair; the authenticated user is +/// attributed via a `p` tag. +pub async fn vote_on_post( + State(state): State>, + headers: HeaderMap, + Path(event_id_hex): Path, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let ctx = extract_auth_context(&headers, &state).await?; + sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite) + .map_err(super::scope_error)?; + let pubkey_bytes = ctx.pubkey_bytes.clone(); + + let direction = body.direction.trim().to_lowercase(); + if direction != "up" && direction != "down" { + return Err(api_error( + StatusCode::BAD_REQUEST, + "direction must be \"up\" or \"down\"", + )); + } + + let event_id_bytes = nostr_hex::decode(&event_id_hex) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event_id hex"))?; + + // Look up the target event. + let stored = state + .db + .get_event_by_id(&event_id_bytes) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))? + .ok_or_else(|| not_found("event not found"))?; + + // Verify the target is a forum post or comment. + let target_kind = u32::from(stored.event.kind.as_u16()); + if target_kind != sprout_core::kind::KIND_FORUM_POST + && target_kind != sprout_core::kind::KIND_FORUM_COMMENT + { + return Err(api_error( + StatusCode::BAD_REQUEST, + "can only vote on forum posts (kind:45001) or forum comments (kind:45003)", + )); + } + + let channel_id = stored + .channel_id + .ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "event has no channel"))?; + + // Token-level channel restriction check. + check_token_channel_access(&ctx, &channel_id)?; + + // Verify channel membership. + check_channel_access(&state, channel_id, &pubkey_bytes).await?; + + let channel = state + .db + .get_channel(channel_id) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))?; + if channel.archived_at.is_some() { + return Err(api_error(StatusCode::FORBIDDEN, "channel is archived")); + } + + // Build the kind:45002 vote event. + let user_pubkey_hex = nostr_hex::encode(&pubkey_bytes); + let kind = Kind::from(sprout_core::kind::KIND_FORUM_VOTE as u16); + + let tags = vec![ + Tag::parse(&["h", &channel_id.to_string()]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + Tag::parse(&["e", &event_id_hex]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + Tag::parse(&["p", &user_pubkey_hex]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + ]; + + let event = EventBuilder::new(kind, &direction, tags) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| internal_error(&format!("event signing error: {e}")))?; + + let vote_event_id_hex = event.id.to_hex(); + + let (stored_vote, was_inserted) = state + .db + .insert_event(&event, Some(channel_id)) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))?; + + if was_inserted { + let kind_u32 = u32::from(event.kind.as_u16()); + let _ = dispatch_persistent_event(&state, &stored_vote, kind_u32, &user_pubkey_hex).await; + } + + Ok(Json(serde_json::json!({ + "event_id": vote_event_id_hex, + "target_event_id": event_id_hex, + "direction": direction, + }))) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 25699df4..5d286d32 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -63,8 +63,8 @@ pub use events::get_event; pub use feed::feed_handler; pub use members::{add_members, join_channel, leave_channel, list_members, remove_member}; pub use messages::{ - delete_message, get_thread, list_messages, send_message, validate_imeta_tags, - verify_imeta_blobs, + delete_message, edit_message, get_thread, list_messages, send_message, validate_imeta_tags, + verify_imeta_blobs, vote_on_post, }; pub use presence::{presence_handler, set_presence_handler}; pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler}; diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index dfa1c85a..9440a9f3 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -146,8 +146,13 @@ pub fn build_router(state: Arc) -> Router { "/api/dms/{channel_id}/members", post(api::add_dm_member_handler), ) - // Message delete route - .route("/api/messages/{event_id}", delete(api::delete_message)) + // Message delete + edit routes + .route( + "/api/messages/{event_id}", + delete(api::delete_message).put(api::edit_message), + ) + // Forum vote route + .route("/api/messages/{event_id}/votes", post(api::vote_on_post)) // Reaction routes .route( "/api/messages/{event_id}/reactions", From 5398ee3d83e49fa9e59cc686999fb9ae201e9ccd Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Sat, 21 Mar 2026 21:03:35 -0400 Subject: [PATCH 2/3] docs: add TESTING.md + fix NIP-98 URL mismatch on localhost - Add crates/sprout-cli/TESTING.md: live testing runbook covering all 48 commands, 3 auth modes, error paths, and cleanup. Crossfired to 8.5/10. - Fix NIP-98 x-forwarded-proto header in auto_mint_token and cmd_auth. The relay defaults x-forwarded-proto to 'https', causing URL mismatch on localhost (http://). Both functions now send the correct scheme header. - Fix workflow YAML examples in TESTING.md to use correct trigger format (tagged enum with 'on:' field, not bare string). - Fix open-dm field extraction (relay returns 'channel_id' not 'id'). --- crates/sprout-cli/TESTING.md | 586 +++++++++++++++++++++++++ crates/sprout-cli/src/client.rs | 10 + crates/sprout-cli/src/commands/auth.rs | 8 + 3 files changed, 604 insertions(+) create mode 100644 crates/sprout-cli/TESTING.md diff --git a/crates/sprout-cli/TESTING.md b/crates/sprout-cli/TESTING.md new file mode 100644 index 00000000..0c4f39ab --- /dev/null +++ b/crates/sprout-cli/TESTING.md @@ -0,0 +1,586 @@ +# sprout-cli Live Testing Guide + +Manual testing runbook for verifying every CLI command against a local relay. +An agent or developer follows this step by step, running each command and +checking the output. + +--- + +## 1. Prerequisites + +Docker services running and healthy: + +```bash +docker compose ps +# sprout-postgres healthy +# sprout-redis healthy +# sprout-typesense healthy +``` + +If not running: `./scripts/dev-setup.sh` from the repo root. + +Tools: `jq`, `curl`, Rust toolchain. + +--- + +## 2. Build the CLI + +```bash +cargo build -p sprout-cli +``` + +Use `cargo run -p sprout-cli --` or the built binary at `target/debug/sprout`. + +--- + +## 3. Start the Relay + +In a separate terminal: + +```bash +cd REPOS/sprout-nostr +set -a && source .env && set +a +cargo run -p sprout-relay +``` + +Verify: + +```bash +curl -s http://localhost:3000/_liveness +# "ok" or 200 status +``` + +The `.env` should have `SPROUT_REQUIRE_AUTH_TOKEN=false` for local dev. + +--- + +## 4. Mint Test Credentials + +### Option A: sprout-admin (full scopes including admin) + +This mints a token with all CLI-relevant scopes (including `admin:channels`) +via direct DB access. Use this for testing admin operations (archive, +delete-channel, add/remove-channel-member). + +```bash +DATABASE_URL=postgres://sprout:sprout_dev@localhost:5432/sprout \ +cargo run -p sprout-admin -- mint-token \ + --name "cli-test" \ + --scopes "messages:read,messages:write,channels:read,channels:write,users:read,users:write,files:read,files:write,admin:channels" +``` + +This generates a keypair and prints: +- **Private key (nsec)** — save for `SPROUT_PRIVATE_KEY` testing +- **API Token** — save as `SPROUT_API_TOKEN` +- **Pubkey** — save for `SPROUT_PUBKEY` testing + +Export: + +```bash +export SPROUT_RELAY_URL="http://localhost:3000" +export SPROUT_API_TOKEN="sprout_tok_..." +export SPROUT_PRIVATE_KEY="nsec1..." # from the mint output +``` + +### Option B: sprout auth (NIP-98, self-mintable scopes only) + +Tests the CLI's own auth flow. Cannot mint `admin:channels`. + +```bash +export SPROUT_PRIVATE_KEY="nsec1..." +export SPROUT_RELAY_URL="http://localhost:3000" +cargo run -p sprout-cli -- auth +# Prints a token string to stdout +``` + +### Scope reference + +| Scope | Self-mintable | Needed for | +|-------|:---:|------------| +| `messages:read` | ✅ | get-messages, get-thread, search, get-feed | +| `messages:write` | ✅ | send-message, edit-message, delete-message, reactions, vote | +| `channels:read` | ✅ | list-channels, get-channel, list-members | +| `channels:write` | ✅ | create-channel, update-channel, join, leave, topic, purpose | +| `users:read` | ✅ | get-users, get-presence | +| `users:write` | ✅ | set-profile, set-presence, set-channel-add-policy | +| `files:read` | ✅ | — | +| `files:write` | ✅ | — | +| `admin:channels` | ❌ | archive, unarchive, delete-channel, add/remove-channel-member | + +**Use Option A for full testing.** Option B covers most commands but skips +admin operations. + +--- + +## 5. Unit Tests + +```bash +cargo test -p sprout-cli +# Expected: 38 passed, 0 failed + +cargo clippy -p sprout-cli -- -D warnings +# Expected: zero warnings +``` + +--- + +## 6. Live Testing — Command by Command + +Run each command, verify exit code 0 and check output. Most commands +return JSON (pipe through `jq .` to validate). Exceptions: `auth` prints +a raw token string, and `delete-token`/`delete-all-tokens` may return +empty (204). Commands are ordered so earlier ones create resources that +later ones need. + +### 6.1 Auth & Tokens + +```bash +# list-tokens — list existing tokens +sprout list-tokens | jq . + +# auth — mint a new token (requires SPROUT_PRIVATE_KEY) +SPROUT_PRIVATE_KEY="nsec1..." sprout auth +# Should print: sprout_tok_... + +# delete-token — delete a specific token by UUID +# ⚠️ Do NOT delete the token you're currently using (SPROUT_API_TOKEN). +# Mint a throwaway token first, then delete it: +THROWAWAY=$(sprout auth) # mint a new token +THROWAWAY_LIST=$(SPROUT_API_TOKEN="$THROWAWAY" sprout list-tokens) +# Filter by name to avoid deleting the wrong token +THROWAWAY_ID=$(echo "$THROWAWAY_LIST" | jq -r '[.[] // .tokens[] | select(.name == "sprout-cli")][0].id // empty') +sprout delete-token --id "$THROWAWAY_ID" +# May return 204 (empty) or JSON — both are success + +# delete-all-tokens — DESTRUCTIVE, deletes all tokens for this pubkey +# sprout delete-all-tokens +# ⚠️ Only run this if you're about to re-mint +``` + +### 6.2 Channels + +```bash +# create-channel (stream) +sprout create-channel --name "test-stream" --type stream --visibility open \ + --description "CLI test channel" | jq . +# Save the channel ID: +CHANNEL_ID=$(sprout create-channel --name "test-cli" --type stream --visibility open | jq -r '.id') + +# create-channel (forum) — needed for vote-on-post later +FORUM_ID=$(sprout create-channel --name "test-forum" --type forum --visibility open | jq -r '.id') + +# list-channels +sprout list-channels | jq . +sprout list-channels --visibility open | jq . +sprout list-channels --member | jq . + +# get-channel +sprout get-channel --channel "$CHANNEL_ID" | jq . + +# update-channel +sprout update-channel --channel "$CHANNEL_ID" --name "test-cli-updated" \ + --description "Updated" | jq . + +# set-channel-topic +sprout set-channel-topic --channel "$CHANNEL_ID" --topic "Test topic" | jq . + +# set-channel-purpose +sprout set-channel-purpose --channel "$CHANNEL_ID" --purpose "Testing" | jq . + +# join-channel (may already be a member from create) +sprout join-channel --channel "$CHANNEL_ID" | jq . + +# leave-channel +sprout leave-channel --channel "$CHANNEL_ID" | jq . + +# Re-join so we can send messages +sprout join-channel --channel "$CHANNEL_ID" | jq . + +# archive-channel (requires admin:channels scope) +sprout archive-channel --channel "$CHANNEL_ID" | jq . + +# unarchive-channel +sprout unarchive-channel --channel "$CHANNEL_ID" | jq . +``` + +### 6.3 Canvas + +```bash +# set-canvas +sprout set-canvas --channel "$CHANNEL_ID" --content "# Test Canvas" | jq . + +# set-canvas from stdin +echo "# Canvas from stdin" | sprout set-canvas --channel "$CHANNEL_ID" --content - | jq . + +# get-canvas +sprout get-canvas --channel "$CHANNEL_ID" | jq . +``` + +### 6.4 Messages + +```bash +# send-message +MSG=$(sprout send-message --channel "$CHANNEL_ID" --content "Hello from CLI test" | jq .) +echo "$MSG" +EVENT_ID=$(echo "$MSG" | jq -r '.id // .event_id') + +# send-message with reply + broadcast +REPLY=$(sprout send-message --channel "$CHANNEL_ID" --content "Reply" \ + --reply-to "$EVENT_ID" --broadcast | jq .) +echo "$REPLY" +REPLY_ID=$(echo "$REPLY" | jq -r '.id // .event_id') + +# send-message with mentions +sprout send-message --channel "$CHANNEL_ID" --content "Hey @someone" \ + --mention "0000000000000000000000000000000000000000000000000000000000000001" | jq . + +# get-messages +sprout get-messages --channel "$CHANNEL_ID" | jq . +sprout get-messages --channel "$CHANNEL_ID" --limit 5 | jq . + +# get-thread +sprout get-thread --channel "$CHANNEL_ID" --event "$EVENT_ID" | jq . + +# search +sprout search --query "Hello" | jq . +sprout search --query "CLI test" --limit 5 | jq . + +# edit-message +sprout edit-message --event "$EVENT_ID" --content "Edited by CLI test" | jq . + +# delete-message +sprout delete-message --event "$REPLY_ID" | jq . +``` + +### 6.5 Diff Messages + +```bash +# send-diff-message from stdin +echo '--- a/foo.rs ++++ b/foo.rs +@@ -1,3 +1,3 @@ +-fn old() {} ++fn new() {}' | sprout send-diff-message \ + --channel "$CHANNEL_ID" \ + --diff - \ + --repo "https://github.com/example/repo" \ + --commit "abcdef1234567890abcdef1234567890abcdef12" | jq . + +# send-diff-message with metadata +echo "diff content" | sprout send-diff-message \ + --channel "$CHANNEL_ID" \ + --diff - \ + --repo "https://github.com/example/repo" \ + --commit "abcdef1234567890abcdef1234567890abcdef12" \ + --file "src/main.rs" \ + --lang "rust" \ + --description "Refactored main" | jq . + +# send-diff-message with branch + PR metadata +echo "diff content" | sprout send-diff-message \ + --channel "$CHANNEL_ID" \ + --diff - \ + --repo "https://github.com/example/repo" \ + --commit "abcdef1234567890abcdef1234567890abcdef12" \ + --parent-commit "1234567890abcdef1234567890abcdef12345678" \ + --source-branch "feature/cli" \ + --target-branch "main" \ + --pr 42 | jq . +``` + +### 6.6 Reactions + +```bash +# Send a message to react to +REACT_MSG=$(sprout send-message --channel "$CHANNEL_ID" --content "React to this") +REACT_ID=$(echo "$REACT_MSG" | jq -r '.id // .event_id') + +# add-reaction +sprout add-reaction --event "$REACT_ID" --emoji "👍" | jq . + +# get-reactions +sprout get-reactions --event "$REACT_ID" | jq . + +# remove-reaction +sprout remove-reaction --event "$REACT_ID" --emoji "👍" | jq . +``` + +### 6.7 DMs + +```bash +# list-dms +sprout list-dms | jq . + +# open-dm (needs a real pubkey — use your own or a test one) +# Get your own pubkey first: +MY_PUBKEY=$(sprout get-users | jq -r '.pubkey // .[0].pubkey // empty') +echo "My pubkey: $MY_PUBKEY" + +# open-dm with a synthetic pubkey (relay will create the user) +DM_RESULT=$(sprout open-dm --pubkey "0000000000000000000000000000000000000000000000000000000000000001") +echo "$DM_RESULT" | jq . +DM_ID=$(echo "$DM_RESULT" | jq -r '.channel_id // .id // empty') + +# add-dm-member (requires messages:write scope — NOT admin:channels) +sprout add-dm-member --channel "$DM_ID" \ + --pubkey "0000000000000000000000000000000000000000000000000000000000000002" | jq . +``` + +### 6.8 Users & Presence + +```bash +# get-users — own profile (0 pubkeys) +sprout get-users | jq . + +# get-users — single pubkey +sprout get-users --pubkey "$MY_PUBKEY" | jq . + +# get-users — batch (2+ pubkeys) +sprout get-users --pubkey "$MY_PUBKEY" --pubkey "$MY_PUBKEY" | jq . + +# set-profile +sprout set-profile --name "CLI Test Agent" --about "Testing sprout-cli" | jq . + +# get-presence +sprout get-presence --pubkeys "$MY_PUBKEY" | jq . + +# set-presence +sprout set-presence --status online | jq . +sprout set-presence --status away | jq . +sprout set-presence --status offline | jq . + +# set-channel-add-policy +sprout set-channel-add-policy --policy anyone | jq . +sprout set-channel-add-policy --policy owner_only | jq . +sprout set-channel-add-policy --policy nobody | jq . +# Reset to default +sprout set-channel-add-policy --policy anyone | jq . +``` + +### 6.9 Channel Members (add/remove require admin:channels) + +```bash +# add-channel-member +sprout add-channel-member --channel "$CHANNEL_ID" \ + --pubkey "0000000000000000000000000000000000000000000000000000000000000001" \ + --role member | jq . + +# list-channel-members +sprout list-channel-members --channel "$CHANNEL_ID" | jq . + +# remove-channel-member +sprout remove-channel-member --channel "$CHANNEL_ID" \ + --pubkey "0000000000000000000000000000000000000000000000000000000000000001" | jq . +``` + +### 6.10 Workflows + +```bash +# create-workflow +# NOTE: trigger uses `on:` tag (serde internally tagged enum). +# Valid triggers: message_posted, reaction_added, diff_posted, schedule, webhook +# Steps use `action:` tag: send_message, send_dm, set_channel_topic, add_reaction, etc. +WF=$(sprout create-workflow --channel "$CHANNEL_ID" \ + --yaml 'name: test-wf +trigger: + on: webhook +steps: + - id: step1 + action: send_message + text: "Hello from workflow"' | jq .) +echo "$WF" +WF_ID=$(echo "$WF" | jq -r '.id') + +# list-workflows +sprout list-workflows --channel "$CHANNEL_ID" | jq . + +# get-workflow +sprout get-workflow --workflow "$WF_ID" | jq . + +# update-workflow +sprout update-workflow --workflow "$WF_ID" \ + --yaml 'name: test-wf-updated +trigger: + on: webhook +steps: + - id: step1 + action: send_message + text: "Updated"' | jq . + +# trigger-workflow +sprout trigger-workflow --workflow "$WF_ID" | jq . + +# get-workflow-runs +sprout get-workflow-runs --workflow "$WF_ID" | jq . + +# approve-step — requires a workflow run waiting for approval +# This is hard to test ad-hoc without a workflow that has an approval gate. +# Test the validation instead: +sprout approve-step --token "00000000-0000-0000-0000-000000000000" --approved true 2>&1 || true +# Should fail with relay error (token not found), not a validation error + +# delete-workflow +sprout delete-workflow --workflow "$WF_ID" | jq . +``` + +### 6.11 Feed + +```bash +sprout get-feed | jq . +sprout get-feed --limit 5 | jq . +``` + +### 6.12 Forum & Voting + +```bash +# Send a forum post (kind 45001) to the forum channel +FORUM_POST=$(sprout send-message --channel "$FORUM_ID" \ + --content "Forum post for vote testing" --kind 45001 | jq .) +echo "$FORUM_POST" +FORUM_EVENT_ID=$(echo "$FORUM_POST" | jq -r '.id // .event_id') + +# vote-on-post (up) +sprout vote-on-post --event "$FORUM_EVENT_ID" --direction up | jq . + +# vote-on-post (down) +sprout vote-on-post --event "$FORUM_EVENT_ID" --direction down | jq . +``` + +--- + +## 7. Error Path Testing + +Verify the CLI produces correct JSON on stderr and correct exit codes. + +```bash +# Exit 1: Invalid UUID +sprout get-channel --channel "not-a-uuid" 2>&1; echo "exit: $?" +# stderr: {"error":"user_error","message":"invalid UUID: not-a-uuid"} +# exit: 1 + +# Exit 1: Invalid hex64 +sprout delete-message --event "not-hex" 2>&1; echo "exit: $?" +# stderr: {"error":"user_error","message":"must be a 64-character hex string: not-hex"} +# exit: 1 + +# Exit 1: Invalid --approved value +sprout approve-step --token "00000000-0000-0000-0000-000000000000" \ + --approved maybe 2>&1; echo "exit: $?" +# stderr: {"error":"user_error","message":"--approved must be 'true' or 'false' (got: maybe)"} +# exit: 1 + +# Exit 1: Invalid --type value +sprout create-channel --name x --type invalid --visibility open 2>&1; echo "exit: $?" +# stderr: {"error":"user_error","message":"--type must be 'stream' or 'forum' (got: invalid)"} +# exit: 1 + +# Exit 1: Invalid --direction value +sprout vote-on-post --event "$(printf '0%.0s' {1..64})" \ + --direction sideways 2>&1; echo "exit: $?" +# exit: 1 + +# Exit 1: Empty body guard +sprout set-profile 2>&1; echo "exit: $?" +# exit: 1 (at least one field required) + +# Exit 3: No auth configured +env -u SPROUT_API_TOKEN -u SPROUT_PRIVATE_KEY -u SPROUT_PUBKEY \ + cargo run -p sprout-cli -- list-channels 2>&1; echo "exit: $?" +# stderr: {"error":"auth_error","message":"auth error: Set SPROUT_API_TOKEN, SPROUT_PRIVATE_KEY, or SPROUT_PUBKEY"} +# exit: 3 + +# Exit 2: Non-existent channel (valid UUID) +sprout get-channel --channel "00000000-0000-0000-0000-000000000000" 2>&1; echo "exit: $?" +# stderr: {"error":"relay_error","message":"..."} +# exit: 2 +``` + +--- + +## 8. Auth Mode Testing + +Test all three authentication tiers. + +```bash +# Mode 1: Bearer token (SPROUT_API_TOKEN) +SPROUT_API_TOKEN="sprout_tok_..." sprout list-channels | jq . +# Should succeed + +# Mode 2: Private key auto-mint (SPROUT_PRIVATE_KEY) +SPROUT_PRIVATE_KEY="nsec1..." sprout list-channels | jq . +# Should succeed (mints a 1-day token at startup) + +# Mode 3: Dev mode (SPROUT_PUBKEY) — only works with SPROUT_REQUIRE_AUTH_TOKEN=false +SPROUT_PUBKEY="" sprout list-channels | jq . +# Should succeed + +# No auth → exit 3 +env -u SPROUT_API_TOKEN -u SPROUT_PRIVATE_KEY -u SPROUT_PUBKEY \ + cargo run -p sprout-cli -- list-channels 2>&1; echo "exit: $?" +# exit: 3 +``` + +--- + +## 9. Cleanup + +```bash +# Delete test channels +sprout delete-channel --channel "$CHANNEL_ID" | jq . +sprout delete-channel --channel "$FORUM_ID" | jq . +``` + +--- + +## 10. Checklist + +| # | Command | Tested | Notes | +|---|---------|:------:|-------| +| 1 | `send-message` | ☐ | Basic, reply, broadcast, mentions | +| 2 | `send-diff-message` | ☐ | Stdin, metadata, branch/PR | +| 3 | `edit-message` | ☐ | | +| 4 | `delete-message` | ☐ | | +| 5 | `get-messages` | ☐ | With limit | +| 6 | `get-thread` | ☐ | | +| 7 | `search` | ☐ | With limit | +| 8 | `list-channels` | ☐ | With visibility, member | +| 9 | `get-channel` | ☐ | | +| 10 | `create-channel` | ☐ | Stream and forum | +| 11 | `update-channel` | ☐ | | +| 12 | `set-channel-topic` | ☐ | | +| 13 | `set-channel-purpose` | ☐ | | +| 14 | `join-channel` | ☐ | | +| 15 | `leave-channel` | ☐ | | +| 16 | `archive-channel` | ☐ | Needs admin:channels | +| 17 | `unarchive-channel` | ☐ | Needs admin:channels | +| 18 | `delete-channel` | ☐ | Needs admin:channels | +| 19 | `list-channel-members` | ☐ | | +| 20 | `add-channel-member` | ☐ | Needs admin:channels | +| 21 | `remove-channel-member` | ☐ | Needs admin:channels | +| 22 | `get-canvas` | ☐ | | +| 23 | `set-canvas` | ☐ | Direct and stdin | +| 24 | `add-reaction` | ☐ | | +| 25 | `remove-reaction` | ☐ | | +| 26 | `get-reactions` | ☐ | | +| 27 | `list-dms` | ☐ | | +| 28 | `open-dm` | ☐ | | +| 29 | `add-dm-member` | ☐ | Needs messages:write | +| 30 | `get-users` | ☐ | Self, single, batch | +| 31 | `set-profile` | ☐ | | +| 32 | `get-presence` | ☐ | | +| 33 | `set-presence` | ☐ | online, away, offline | +| 34 | `set-channel-add-policy` | ☐ | anyone, owner_only, nobody | +| 35 | `list-workflows` | ☐ | | +| 36 | `create-workflow` | ☐ | | +| 37 | `update-workflow` | ☐ | | +| 38 | `delete-workflow` | ☐ | | +| 39 | `trigger-workflow` | ☐ | | +| 40 | `get-workflow-runs` | ☐ | | +| 41 | `get-workflow` | ☐ | | +| 42 | `approve-step` | ☐ | Validation only (needs approval gate) | +| 43 | `get-feed` | ☐ | | +| 44 | `vote-on-post` | ☐ | Up and down | +| 45 | `auth` | ☐ | Mint token via NIP-98 | +| 46 | `list-tokens` | ☐ | | +| 47 | `delete-token` | ☐ | | +| 48 | `delete-all-tokens` | ☐ | Optional (destructive) | diff --git a/crates/sprout-cli/src/client.rs b/crates/sprout-cli/src/client.rs index ed991886..35f86a09 100644 --- a/crates/sprout-cli/src/client.rs +++ b/crates/sprout-cli/src/client.rs @@ -197,9 +197,19 @@ pub async fn auto_mint_token(relay_url: &str, private_key_str: &str) -> Result) -> Result<(), let resp = http .post(&token_url) .header("Authorization", auth_header) + .header( + "x-forwarded-proto", + if token_url.starts_with("https://") { + "https" + } else { + "http" + }, + ) .json(&body) .send() .await From 890b093ee714dad7d22dd99ae2e18cffda4f74c3 Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Sat, 21 Mar 2026 21:13:40 -0400 Subject: [PATCH 3/3] fix: add workspace metadata to sprout-cli Cargo.toml (license check) --- crates/sprout-cli/Cargo.toml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/sprout-cli/Cargo.toml b/crates/sprout-cli/Cargo.toml index 089ac66c..bb9dd4db 100644 --- a/crates/sprout-cli/Cargo.toml +++ b/crates/sprout-cli/Cargo.toml @@ -1,7 +1,11 @@ [package] name = "sprout-cli" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Agent-first CLI for Sprout relay" [[bin]] name = "sprout"