diff --git a/Cargo.lock b/Cargo.lock index 9998615cd..5060ed654 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3872,6 +3872,7 @@ dependencies = [ "serde", "serde_json", "similar", + "sprout-cli", "tempfile", "tokio", "tokio-util", diff --git a/crates/sprout-cli/Cargo.toml b/crates/sprout-cli/Cargo.toml index 2495590d9..314930521 100644 --- a/crates/sprout-cli/Cargo.toml +++ b/crates/sprout-cli/Cargo.toml @@ -7,6 +7,10 @@ license.workspace = true repository.workspace = true description = "Agent-first CLI for Sprout relay" +[lib] +name = "sprout_cli" +path = "src/lib.rs" + [[bin]] name = "sprout" path = "src/main.rs" diff --git a/crates/sprout-cli/src/client.rs b/crates/sprout-cli/src/client.rs index 3311d796c..9789e9be2 100644 --- a/crates/sprout-cli/src/client.rs +++ b/crates/sprout-cli/src/client.rs @@ -123,10 +123,19 @@ pub struct SproutClient { http: reqwest::Client, relay_url: String, // base URL, no trailing slash, e.g. "https://relay.sprout.place" keys: Keys, + /// Optional NIP-OA auth tag injected into every signed event. + auth_tag: Option, + /// Raw JSON of the auth tag for the `x-auth-tag` HTTP header. + auth_tag_json: Option, } impl SproutClient { - pub fn new(relay_url: String, keys: Keys) -> Result { + pub fn new( + relay_url: String, + keys: Keys, + auth_tag: Option, + auth_tag_json: Option, + ) -> Result { let http = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .connect_timeout(Duration::from_secs(5)) @@ -136,6 +145,8 @@ impl SproutClient { http, relay_url, keys, + auth_tag, + auth_tag_json, }) } @@ -150,6 +161,46 @@ impl SproutClient { &self.relay_url } + /// Sign an event builder, injecting the NIP-OA auth tag if configured. + /// + /// All event creation should go through this method to ensure consistent + /// auth tag injection. Callers MUST NOT add `auth` tags to the builder + /// before calling this method. + pub fn sign_event(&self, builder: EventBuilder) -> Result { + let builder = if let Some(ref tag) = self.auth_tag { + builder.add_tags([tag.clone()]) + } else { + builder + }; + let event = builder + .sign_with_keys(&self.keys) + .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + + // Enforce: auth tags may only come from self.auth_tag injection. + let auth_count = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) + .count(); + let expected = if self.auth_tag.is_some() { 1 } else { 0 }; + if auth_count != expected { + return Err(CliError::Other(format!( + "event has {auth_count} auth tags — expected {expected}; \ + callers must not add auth tags manually" + ))); + } + + Ok(event) + } + + /// Attach the `x-auth-tag` header if configured (NIP-OA relay membership delegation). + fn with_auth_tag(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + match self.auth_tag_json { + Some(ref json) => req.header("x-auth-tag", json), + None => req, + } + } + // ----------------------------------------------------------------------- // HTTP Bridge: POST /query // ----------------------------------------------------------------------- @@ -163,14 +214,13 @@ impl SproutClient { .map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?; let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?; - let resp = self + let req = self .http .post(&url) .header("Authorization", &auth) .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await?; + .body(body_bytes); + let resp = self.with_auth_tag(req).send().await?; self.handle_response(resp).await } @@ -184,14 +234,13 @@ impl SproutClient { .map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?; let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?; - let resp = self + let req = self .http .post(&url) .header("Authorization", &auth) .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await?; + .body(body_bytes); + let resp = self.with_auth_tag(req).send().await?; self.handle_response(resp).await } @@ -207,14 +256,13 @@ impl SproutClient { .map_err(|e| CliError::Other(format!("event serialization failed: {e}")))?; let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?; - let resp = self + let req = self .http .post(&url) .header("Authorization", &auth) .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await?; + .body(body_bytes); + let resp = self.with_auth_tag(req).send().await?; self.handle_response(resp).await } @@ -316,7 +364,7 @@ impl SproutClient { .header("Content-Type", &mime) .header("X-SHA-256", &sha256); - let resp = req.body(bytes).send().await?; + let resp = self.with_auth_tag(req).body(bytes).send().await?; if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); diff --git a/crates/sprout-cli/src/commands/channels.rs b/crates/sprout-cli/src/commands/channels.rs index effcb60e1..f7c08bb36 100644 --- a/crates/sprout-cli/src/commands/channels.rs +++ b/crates/sprout-cli/src/commands/channels.rs @@ -14,11 +14,9 @@ fn parse_uuid(s: &str) -> Result { fn sign_and_submit_builder( builder: nostr::EventBuilder, - keys: &nostr::Keys, + client: &SproutClient, ) -> Result { - builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}"))) + client.sign_event(builder) } // --------------------------------------------------------------------------- @@ -113,7 +111,6 @@ pub async fn cmd_create_channel( } } - let keys = client.keys(); let channel_uuid = Uuid::new_v4(); let vis = match visibility { @@ -130,7 +127,7 @@ pub async fn cmd_create_channel( sprout_sdk::build_create_channel(channel_uuid, name, Some(vis), Some(ct), description) .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -148,13 +145,12 @@ pub async fn cmd_update_channel( )); } validate_uuid(channel_id)?; - let keys = client.keys(); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_update_channel(channel_uuid, name, description) .map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -166,13 +162,12 @@ pub async fn cmd_set_channel_topic( topic: &str, ) -> Result<(), CliError> { validate_uuid(channel_id)?; - let keys = client.keys(); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_set_topic(channel_uuid, topic) .map_err(|e| CliError::Other(format!("build_set_topic failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -184,13 +179,12 @@ pub async fn cmd_set_channel_purpose( purpose: &str, ) -> Result<(), CliError> { validate_uuid(channel_id)?; - let keys = client.keys(); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_set_purpose(channel_uuid, purpose) .map_err(|e| CliError::Other(format!("build_set_purpose failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -198,13 +192,12 @@ pub async fn cmd_set_channel_purpose( pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { validate_uuid(channel_id)?; - let keys = client.keys(); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_join(channel_uuid) .map_err(|e| CliError::Other(format!("build_join failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -212,13 +205,12 @@ pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { validate_uuid(channel_id)?; - let keys = client.keys(); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_leave(channel_uuid) .map_err(|e| CliError::Other(format!("build_leave failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -226,13 +218,12 @@ pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Resul pub async fn cmd_archive_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { validate_uuid(channel_id)?; - let keys = client.keys(); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_archive(channel_uuid) .map_err(|e| CliError::Other(format!("build_archive failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -243,13 +234,12 @@ pub async fn cmd_unarchive_channel( channel_id: &str, ) -> Result<(), CliError> { validate_uuid(channel_id)?; - let keys = client.keys(); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_unarchive(channel_uuid) .map_err(|e| CliError::Other(format!("build_unarchive failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -257,13 +247,12 @@ pub async fn cmd_unarchive_channel( pub async fn cmd_delete_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { validate_uuid(channel_id)?; - let keys = client.keys(); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_delete_channel(channel_uuid) .map_err(|e| CliError::Other(format!("build_delete_channel failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -277,7 +266,6 @@ pub async fn cmd_add_channel_member( ) -> Result<(), CliError> { validate_uuid(channel_id)?; validate_hex64(pubkey)?; - let keys = client.keys(); let channel_uuid = parse_uuid(channel_id)?; let typed_role = match role { @@ -296,7 +284,7 @@ pub async fn cmd_add_channel_member( let builder = sprout_sdk::build_add_member(channel_uuid, pubkey, typed_role) .map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -309,13 +297,12 @@ pub async fn cmd_remove_channel_member( ) -> Result<(), CliError> { validate_uuid(channel_id)?; validate_hex64(pubkey)?; - let keys = client.keys(); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_remove_member(channel_uuid, pubkey) .map_err(|e| CliError::Other(format!("build_remove_member failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -328,13 +315,12 @@ pub async fn cmd_set_canvas( ) -> Result<(), CliError> { validate_uuid(channel_id)?; let content = read_or_stdin(content)?; - let keys = client.keys(); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_set_canvas(channel_uuid, &content) .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) diff --git a/crates/sprout-cli/src/commands/dms.rs b/crates/sprout-cli/src/commands/dms.rs index d1441e7c2..b92e24ee3 100644 --- a/crates/sprout-cli/src/commands/dms.rs +++ b/crates/sprout-cli/src/commands/dms.rs @@ -27,16 +27,13 @@ pub async fn cmd_open_dm(client: &SproutClient, pubkeys: &[String]) -> Result<() validate_hex64(pk)?; } - let keys = client.keys(); let mut tags: Vec = Vec::new(); for pk in pubkeys { tags.push(Tag::parse(&["p", pk]).map_err(|e| CliError::Other(format!("tag error: {e}")))?); } let builder = EventBuilder::new(Kind::Custom(41010), "", tags); - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -52,16 +49,13 @@ pub async fn cmd_add_dm_member( validate_uuid(channel_id)?; validate_hex64(pubkey)?; - let keys = client.keys(); let tags = vec![ Tag::parse(&["h", channel_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?, Tag::parse(&["p", pubkey]).map_err(|e| CliError::Other(format!("tag error: {e}")))?, ]; let builder = EventBuilder::new(Kind::Custom(41011), "", tags); - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/sprout-cli/src/commands/messages.rs b/crates/sprout-cli/src/commands/messages.rs index e9cfb125c..7ee69cedc 100644 --- a/crates/sprout-cli/src/commands/messages.rs +++ b/crates/sprout-cli/src/commands/messages.rs @@ -196,7 +196,6 @@ pub async fn cmd_send_message(client: &SproutClient, p: SendMessageParams) -> Re validate_hex64(m)?; } - let keys = client.keys(); let channel_uuid = parse_uuid(&p.channel_id)?; // Upload files and build imeta tags @@ -249,9 +248,7 @@ pub async fn cmd_send_message(client: &SproutClient, p: SendMessageParams) -> Re ) .map_err(|e| CliError::Other(format!("build_message failed: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -292,7 +289,6 @@ pub async fn cmd_send_diff_message( _ => {} } - let keys = client.keys(); let channel_uuid = parse_uuid(&p.channel_id)?; // Read diff from stdin if "--diff -" @@ -346,9 +342,7 @@ pub async fn cmd_send_diff_message( sprout_sdk::build_diff_message(channel_uuid, &diff, &diff_meta, thread_ref.as_ref()) .map_err(|e| CliError::Other(format!("build_diff_message failed: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -357,7 +351,6 @@ pub async fn cmd_send_diff_message( pub async fn cmd_delete_message(client: &SproutClient, event_id: &str) -> Result<(), CliError> { validate_hex64(event_id)?; - let keys = client.keys(); // Resolve channel_id from the event's h-tag let channel_uuid = resolve_channel_id(client, event_id).await?; @@ -366,9 +359,7 @@ pub async fn cmd_delete_message(client: &SproutClient, event_id: &str) -> Result let builder = sprout_sdk::build_delete_message(channel_uuid, target_eid) .map_err(|e| CliError::Other(format!("build_delete_message failed: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -383,7 +374,6 @@ pub async fn cmd_edit_message( ) -> Result<(), CliError> { validate_hex64(event_id)?; validate_content_size(content)?; - let keys = client.keys(); // Resolve channel_id from the event's h-tag let channel_uuid = resolve_channel_id(client, event_id).await?; @@ -392,9 +382,7 @@ pub async fn cmd_edit_message( let builder = sprout_sdk::build_edit(channel_uuid, target_eid, content) .map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -418,8 +406,6 @@ pub async fn cmd_vote_on_post( } }; - let keys = client.keys(); - // Resolve channel_id from the event's h-tag let channel_uuid = resolve_channel_id(client, event_id).await?; let target_eid = parse_event_id(event_id)?; @@ -427,9 +413,7 @@ pub async fn cmd_vote_on_post( let builder = sprout_sdk::build_vote(channel_uuid, target_eid, vote_dir) .map_err(|e| CliError::Other(format!("build_vote failed: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/sprout-cli/src/commands/pack.rs b/crates/sprout-cli/src/commands/pack.rs index f080d449b..fe893c50c 100644 --- a/crates/sprout-cli/src/commands/pack.rs +++ b/crates/sprout-cli/src/commands/pack.rs @@ -3,7 +3,6 @@ //! These commands operate on local pack directories. No relay connection needed. use std::path::Path; -use std::process; use crate::error::CliError; @@ -16,12 +15,10 @@ use crate::error::CliError; pub fn cmd_validate(path: &str) -> Result<(), CliError> { let pack_dir = Path::new(path); if !pack_dir.exists() { - eprintln!("error: path does not exist: {path}"); - process::exit(1); + return Err(CliError::Usage(format!("path does not exist: {path}"))); } if !pack_dir.is_dir() { - eprintln!("error: not a directory: {path}"); - process::exit(1); + return Err(CliError::Usage(format!("not a directory: {path}"))); } let report = sprout_persona::validate::validate_pack(pack_dir); @@ -38,8 +35,7 @@ pub fn cmd_validate(path: &str) -> Result<(), CliError> { } if report.has_errors() { - eprintln!("\nValidation failed."); - process::exit(1); + return Err(CliError::Usage("Validation failed.".into())); } else if report.has_warnings() { println!("Valid (with warnings)."); } else { @@ -56,12 +52,10 @@ pub fn cmd_validate(path: &str) -> Result<(), CliError> { pub fn cmd_inspect(path: &str) -> Result<(), CliError> { let pack_dir = Path::new(path); if !pack_dir.exists() { - eprintln!("error: path does not exist: {path}"); - process::exit(1); + return Err(CliError::Usage(format!("path does not exist: {path}"))); } if !pack_dir.is_dir() { - eprintln!("error: not a directory: {path}"); - process::exit(1); + return Err(CliError::Usage(format!("not a directory: {path}"))); } // Resolve the pack — shows fully effective config (post-merge, post-split). diff --git a/crates/sprout-cli/src/commands/reactions.rs b/crates/sprout-cli/src/commands/reactions.rs index defa3b08b..b1d36b3a6 100644 --- a/crates/sprout-cli/src/commands/reactions.rs +++ b/crates/sprout-cli/src/commands/reactions.rs @@ -10,17 +10,13 @@ pub async fn cmd_add_reaction( emoji: &str, ) -> Result<(), CliError> { validate_hex64(event_id)?; - let keys = client.keys(); - let target_eid = EventId::parse(event_id).map_err(|e| CliError::Usage(format!("invalid event ID: {e}")))?; let builder = sprout_sdk::build_reaction(target_eid, emoji) .map_err(|e| CliError::Other(format!("build_reaction failed: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -66,9 +62,7 @@ pub async fn cmd_remove_reaction( let builder = sprout_sdk::build_remove_reaction(reaction_eid) .map_err(|e| CliError::Other(format!("build_remove_reaction failed: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/sprout-cli/src/commands/social.rs b/crates/sprout-cli/src/commands/social.rs index 79ba1a71e..efc3b68ce 100644 --- a/crates/sprout-cli/src/commands/social.rs +++ b/crates/sprout-cli/src/commands/social.rs @@ -29,15 +29,12 @@ pub async fn cmd_publish_note( validate_hex64(r)?; } - let keys = client.keys(); let reply_id = reply_to.map(parse_event_id).transpose()?; let builder = sprout_sdk::build_note(content, reply_id) .map_err(|e| CliError::Other(format!("build error: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -48,7 +45,6 @@ pub async fn cmd_set_contact_list( client: &SproutClient, contacts_json: &str, ) -> Result<(), CliError> { - let keys = client.keys(); let entries: Vec = serde_json::from_str(contacts_json) .map_err(|e| CliError::Usage(format!("invalid contacts JSON: {e}")))?; @@ -66,9 +62,7 @@ pub async fn cmd_set_contact_list( let builder = sprout_sdk::build_contact_list(&contacts) .map_err(|e| CliError::Other(format!("build error: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/sprout-cli/src/commands/users.rs b/crates/sprout-cli/src/commands/users.rs index e4b6f7ee2..97737fea8 100644 --- a/crates/sprout-cli/src/commands/users.rs +++ b/crates/sprout-cli/src/commands/users.rs @@ -46,8 +46,6 @@ pub async fn cmd_set_profile( )); } - let keys = client.keys(); - // Read-merge-write: fetch current profile, merge in the new fields, then sign. let current = fetch_current_profile(client).await?; @@ -94,9 +92,7 @@ pub async fn cmd_set_profile( ) .map_err(|e| CliError::Other(format!("build_profile failed: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -170,7 +166,6 @@ pub async fn cmd_set_presence(client: &SproutClient, status: &str) -> Result<(), } } - let keys = client.keys(); let tags = vec![Tag::parse(&["status", status]) .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; @@ -178,9 +173,7 @@ pub async fn cmd_set_presence(client: &SproutClient, status: &str) -> Result<(), // KIND_PRESENCE_UPDATE (20001) — ephemeral, WS-only. HTTP bridge will reject this // until the CLI gains a WebSocket publish path. let builder = EventBuilder::new(Kind::Custom(20001), "", tags); - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/sprout-cli/src/commands/workflows.rs b/crates/sprout-cli/src/commands/workflows.rs index 319e69c9a..5d0e3f8e1 100644 --- a/crates/sprout-cli/src/commands/workflows.rs +++ b/crates/sprout-cli/src/commands/workflows.rs @@ -63,7 +63,6 @@ pub async fn cmd_create_workflow( ) -> Result<(), CliError> { validate_uuid(channel_id)?; let yaml_definition = read_or_stdin(yaml)?; - let keys = client.keys(); // Generate a unique d-tag for this workflow let workflow_id = uuid::Uuid::new_v4().to_string(); @@ -73,9 +72,7 @@ pub async fn cmd_create_workflow( ]; let builder = EventBuilder::new(Kind::Custom(30620), &yaml_definition, tags); - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -90,16 +87,13 @@ pub async fn cmd_update_workflow( ) -> Result<(), CliError> { validate_uuid(workflow_id)?; let yaml_definition = read_or_stdin(yaml)?; - let keys = client.keys(); let tags = vec![Tag::parse(&["d", workflow_id]) .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; let builder = EventBuilder::new(Kind::Custom(30620), &yaml_definition, tags); - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -119,9 +113,7 @@ pub async fn cmd_delete_workflow(client: &SproutClient, workflow_id: &str) -> Re .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; let builder = EventBuilder::new(Kind::Custom(5), "", tags); - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -134,16 +126,13 @@ pub async fn cmd_trigger_workflow( workflow_id: &str, ) -> Result<(), CliError> { validate_uuid(workflow_id)?; - let keys = client.keys(); let tags = vec![Tag::parse(&["d", workflow_id]) .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; let builder = EventBuilder::new(Kind::Custom(46020), "", tags); - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -158,7 +147,6 @@ pub async fn cmd_approve_step( note: Option<&str>, ) -> Result<(), CliError> { validate_uuid(approval_token)?; - let keys = client.keys(); let kind = if approved { 46030 } else { 46031 }; let content = note.unwrap_or(""); @@ -170,9 +158,7 @@ pub async fn cmd_approve_step( .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; let builder = EventBuilder::new(Kind::Custom(kind), content, tags); - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs new file mode 100644 index 000000000..e435d8913 --- /dev/null +++ b/crates/sprout-cli/src/lib.rs @@ -0,0 +1,946 @@ +mod client; +mod commands; +mod error; +mod validate; + +use clap::{Parser, Subcommand}; +use client::SproutClient; +use error::CliError; +use nostr::Keys; + +/// Run the Sprout CLI from raw arguments (including `argv[0]`). +/// +/// Returns a process exit code (0 = success). +/// +/// # Example +/// +/// ```ignore +/// let code = sprout_cli::run_from_args(std::env::args()).await; +/// std::process::exit(code); +/// ``` +pub async fn run_from_args(args: I) -> i32 +where + I: IntoIterator, + S: Into + Clone, +{ + let cli = match Cli::try_parse_from(args) { + Ok(cli) => cli, + Err(e) => { + if e.use_stderr() { + error::print_error(&CliError::Usage(e.to_string())); + return 1; + } else { + // --help and --version: print normally (intentional human output) + let _ = e.print(); + return 0; + } + } + }; + match run(cli).await { + Ok(()) => 0, + Err(e) => { + error::print_error(&e); + error::exit_code(&e) + } + } +} + +// --------------------------------------------------------------------------- +// 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, + + /// Nostr private key (hex or nsec). This is the CLI's identity. + #[arg(long, env = "SPROUT_PRIVATE_KEY")] + private_key: Option, + + /// NIP-OA auth tag JSON (owner attestation). Injected into every signed event. + #[arg(long, env = "SPROUT_AUTH_TAG")] + auth_tag: 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, + /// Attach file(s) — uploads and includes as imeta tags + #[arg(long = "file")] + files: 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)] + since: 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, + }, + /// 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, + }, + + /// Upload a file to the relay (returns BlobDescriptor JSON) + UploadFile { + /// Path to the file to upload + #[arg(long)] + file: 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)] + 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, + }, + + // ---- 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 { + /// The approval token UUID (from the approval request) + #[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, + }, + + // Social + /// Publish a short text note (kind:1) to the global feed. + #[command(name = "publish-note")] + PublishNote { + /// Text content of the note. + #[arg(long)] + content: String, + /// 64-char hex event ID to reply to. + #[arg(long)] + reply_to: Option, + }, + + /// Set the authenticated user's contact/follow list (kind:3). Replaces the entire list. + #[command(name = "set-contact-list")] + SetContactList { + /// JSON array of contacts: [{"pubkey":"hex","relay_url":"...","petname":"..."}] + #[arg(long)] + contacts: String, + }, + + /// Get a single event by event ID (notes, profiles, contacts, articles, channel events). + #[command(name = "get-event")] + GetEvent { + /// 64-char hex event ID. + #[arg(long)] + event: String, + }, + + /// List kind:1 text notes by a specific user. + #[command(name = "get-user-notes")] + GetUserNotes { + /// 64-char hex pubkey of the author. + #[arg(long)] + pubkey: String, + /// Maximum number of notes to return (default 50, max 100). + #[arg(long)] + limit: Option, + /// Unix timestamp cursor — return notes created before this time. + #[arg(long)] + before: Option, + }, + + /// Get a user's contact/follow list (kind:3) by hex pubkey. + #[command(name = "get-contact-list")] + GetContactList { + /// 64-char hex pubkey. + #[arg(long)] + pubkey: String, + }, + + // ---- Pack (local) ------------------------------------------------------ + /// Persona pack operations (local, no relay connection needed) + #[command(subcommand)] + Pack(PackCmd), +} + +/// Subcommands for `sprout pack`. +#[derive(Subcommand)] +enum PackCmd { + /// Validate a persona pack directory + Validate { + /// Path to the pack directory + path: String, + }, + /// Inspect a persona pack — show metadata and effective config + Inspect { + /// Path to the pack directory + path: String, + }, +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// 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); + + // Pack commands are local-only — no relay connection needed. + if let Cmd::Pack(ref sub) = cli.command { + return match sub { + PackCmd::Validate { path } => commands::pack::cmd_validate(path), + PackCmd::Inspect { path } => commands::pack::cmd_inspect(path), + }; + } + + // Auth: private key is required for all relay operations. + // The keypair IS the identity — no tokens, no other auth. + let private_key_str = cli.private_key.ok_or_else(|| { + CliError::Auth("SPROUT_PRIVATE_KEY is required (use --private-key or set env var)".into()) + })?; + let keys = Keys::parse(&private_key_str) + .map_err(|e| CliError::Key(format!("invalid SPROUT_PRIVATE_KEY: {e}")))?; + + // NIP-OA: parse and verify the auth tag if provided. + let (auth_tag, auth_tag_json) = match cli.auth_tag { + Some(ref json) if !json.is_empty() => { + let tag = sprout_sdk::nip_oa::parse_auth_tag(json) + .map_err(|e| CliError::Auth(format!("SPROUT_AUTH_TAG is malformed: {e}")))?; + sprout_sdk::nip_oa::verify_auth_tag(json, &keys.public_key()).map_err(|e| { + CliError::Auth(format!( + "SPROUT_AUTH_TAG verification failed for pubkey {}: {e}", + keys.public_key().to_hex() + )) + })?; + (Some(tag), Some(json.clone())) + } + _ => (None, None), + }; + + let client = SproutClient::new(relay_url, keys, auth_tag, auth_tag_json)?; + + match cli.command { + // ---- Messages ------------------------------------------------------ + Cmd::SendMessage { + channel, + content, + kind, + reply_to, + broadcast, + mentions, + files, + } => { + commands::messages::cmd_send_message( + &client, + commands::messages::SendMessageParams { + channel_id: channel, + content, + kind, + reply_to, + broadcast, + mentions, + files, + }, + ) + .await + } + Cmd::SendDiffMessage { + channel, + diff, + repo, + commit, + file, + parent_commit, + source_branch, + target_branch, + pr, + lang, + description, + reply_to, + } => { + commands::messages::cmd_send_diff_message( + &client, + commands::messages::SendDiffParams { + channel_id: channel, + diff, + repo_url: repo, + commit_sha: commit, + file_path: file, + parent_commit_sha: parent_commit, + source_branch, + target_branch, + pr_number: pr, + language: lang, + description, + reply_to, + }, + ) + .await + } + Cmd::DeleteMessage { event } => { + commands::messages::cmd_delete_message(&client, &event).await + } + Cmd::GetMessages { + channel, + limit, + before, + since, + kinds, + } => { + commands::messages::cmd_get_messages( + &client, + &channel, + limit, + before, + since, + kinds.as_deref(), + ) + .await + } + Cmd::GetThread { + channel, + event, + depth_limit, + limit, + } => { + commands::messages::cmd_get_thread(&client, &channel, &event, depth_limit, limit).await + } + Cmd::Search { query, limit } => { + commands::messages::cmd_search(&client, &query, limit).await + } + Cmd::EditMessage { event, content } => { + commands::messages::cmd_edit_message(&client, &event, &content).await + } + Cmd::VoteOnPost { event, direction } => { + commands::messages::cmd_vote_on_post(&client, &event, &direction).await + } + Cmd::UploadFile { file } => { + let desc = client.upload_file(&file).await?; + println!( + "{}", + serde_json::to_string_pretty(&desc).map_err(|e| CliError::Other(e.to_string()))? + ); + Ok(()) + } + + // ---- Channels ------------------------------------------------------ + Cmd::ListChannels { visibility, member } => { + commands::channels::cmd_list_channels(&client, visibility.as_deref(), Some(member)) + .await + } + Cmd::GetChannel { channel } => commands::channels::cmd_get_channel(&client, &channel).await, + Cmd::CreateChannel { + name, + channel_type, + visibility, + description, + } => { + commands::channels::cmd_create_channel( + &client, + &name, + &channel_type, + &visibility, + description.as_deref(), + ) + .await + } + Cmd::UpdateChannel { + channel, + name, + description, + } => { + commands::channels::cmd_update_channel( + &client, + &channel, + name.as_deref(), + description.as_deref(), + ) + .await + } + Cmd::SetChannelTopic { channel, topic } => { + commands::channels::cmd_set_channel_topic(&client, &channel, &topic).await + } + Cmd::SetChannelPurpose { channel, purpose } => { + commands::channels::cmd_set_channel_purpose(&client, &channel, &purpose).await + } + Cmd::JoinChannel { channel } => { + commands::channels::cmd_join_channel(&client, &channel).await + } + Cmd::LeaveChannel { channel } => { + commands::channels::cmd_leave_channel(&client, &channel).await + } + Cmd::ArchiveChannel { channel } => { + commands::channels::cmd_archive_channel(&client, &channel).await + } + Cmd::UnarchiveChannel { channel } => { + commands::channels::cmd_unarchive_channel(&client, &channel).await + } + Cmd::DeleteChannel { channel } => { + commands::channels::cmd_delete_channel(&client, &channel).await + } + Cmd::ListChannelMembers { channel } => { + commands::channels::cmd_list_channel_members(&client, &channel).await + } + Cmd::AddChannelMember { + channel, + pubkey, + role, + } => { + commands::channels::cmd_add_channel_member(&client, &channel, &pubkey, role.as_deref()) + .await + } + Cmd::RemoveChannelMember { channel, pubkey } => { + commands::channels::cmd_remove_channel_member(&client, &channel, &pubkey).await + } + Cmd::GetCanvas { channel } => commands::channels::cmd_get_canvas(&client, &channel).await, + Cmd::SetCanvas { channel, content } => { + commands::channels::cmd_set_canvas(&client, &channel, &content).await + } + + // ---- Reactions ----------------------------------------------------- + Cmd::AddReaction { event, emoji } => { + commands::reactions::cmd_add_reaction(&client, &event, &emoji).await + } + Cmd::RemoveReaction { event, emoji } => { + commands::reactions::cmd_remove_reaction(&client, &event, &emoji).await + } + Cmd::GetReactions { event } => { + commands::reactions::cmd_get_reactions(&client, &event).await + } + + // ---- DMs ----------------------------------------------------------- + Cmd::ListDms { limit } => commands::dms::cmd_list_dms(&client, limit).await, + Cmd::OpenDm { pubkeys } => commands::dms::cmd_open_dm(&client, &pubkeys).await, + Cmd::AddDmMember { channel, pubkey } => { + commands::dms::cmd_add_dm_member(&client, &channel, &pubkey).await + } + + // ---- Users --------------------------------------------------------- + Cmd::GetUsers { pubkeys } => 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, + + // ---- 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, + + // ---- Social -------------------------------------------------------- + Cmd::PublishNote { content, reply_to } => { + commands::social::cmd_publish_note(&client, &content, reply_to.as_deref()).await + } + Cmd::SetContactList { contacts } => { + commands::social::cmd_set_contact_list(&client, &contacts).await + } + Cmd::GetEvent { event } => commands::social::cmd_get_event(&client, &event).await, + Cmd::GetUserNotes { + pubkey, + limit, + before, + } => commands::social::cmd_get_user_notes(&client, &pubkey, limit, before).await, + Cmd::GetContactList { pubkey } => { + commands::social::cmd_get_contact_list(&client, &pubkey).await + } + + // ---- Pack (local) -------------------------------------------------- + Cmd::Pack(_) => unreachable!("handled above"), + } +} + +// --------------------------------------------------------------------------- +// 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 set of commands. + /// Token commands removed (auth, list-tokens, delete-token, delete-all-tokens). + /// SetChannelAddPolicy removed (relay-side policy now). + /// Cursor removed from get-thread, list-dms. before_id removed from get-user-notes. + #[test] + fn command_inventory_is_stable() { + let expected: Vec<&str> = vec![ + "add-channel-member", + "add-dm-member", + "add-reaction", + "approve-step", + "archive-channel", + "create-channel", + "create-workflow", + "delete-channel", + "delete-message", + "delete-workflow", + "edit-message", + "get-canvas", + "get-channel", + "get-contact-list", + "get-event", + "get-feed", + "get-messages", + "get-presence", + "get-reactions", + "get-thread", + "get-user-notes", + "get-users", + "get-workflow", + "get-workflow-runs", + "join-channel", + "leave-channel", + "list-channel-members", + "list-channels", + "list-dms", + "list-workflows", + "open-dm", + "pack", + "publish-note", + "remove-channel-member", + "remove-reaction", + "search", + "send-diff-message", + "send-message", + "set-canvas", + "set-channel-purpose", + "set-channel-topic", + "set-contact-list", + "set-presence", + "set-profile", + "trigger-workflow", + "unarchive-channel", + "update-channel", + "update-workflow", + "upload-file", + "vote-on-post", + ]; + + 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(), + expected.len(), + "Expected {} commands, got {}. Actual: {:?}", + expected.len(), + actual.len(), + actual + ); + assert_eq!(actual, expected, "Command inventory drift detected"); + } +} diff --git a/crates/sprout-cli/src/main.rs b/crates/sprout-cli/src/main.rs index 57c105c8b..e98f4715a 100644 --- a/crates/sprout-cli/src/main.rs +++ b/crates/sprout-cli/src/main.rs @@ -1,913 +1,4 @@ -mod client; -mod commands; -mod error; -mod validate; - -use clap::{Parser, Subcommand}; -use client::SproutClient; -use error::CliError; -use nostr::Keys; - -// --------------------------------------------------------------------------- -// 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, - - /// Nostr private key (hex or nsec). This is the CLI's identity. - #[arg(long, env = "SPROUT_PRIVATE_KEY")] - private_key: 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, - /// Attach file(s) — uploads and includes as imeta tags - #[arg(long = "file")] - files: 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)] - since: 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, - }, - /// 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, - }, - - /// Upload a file to the relay (returns BlobDescriptor JSON) - UploadFile { - /// Path to the file to upload - #[arg(long)] - file: 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)] - 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, - }, - - // ---- 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 { - /// The approval token UUID (from the approval request) - #[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, - }, - - // Social - /// Publish a short text note (kind:1) to the global feed. - #[command(name = "publish-note")] - PublishNote { - /// Text content of the note. - #[arg(long)] - content: String, - /// 64-char hex event ID to reply to. - #[arg(long)] - reply_to: Option, - }, - - /// Set the authenticated user's contact/follow list (kind:3). Replaces the entire list. - #[command(name = "set-contact-list")] - SetContactList { - /// JSON array of contacts: [{"pubkey":"hex","relay_url":"...","petname":"..."}] - #[arg(long)] - contacts: String, - }, - - /// Get a single event by event ID (notes, profiles, contacts, articles, channel events). - #[command(name = "get-event")] - GetEvent { - /// 64-char hex event ID. - #[arg(long)] - event: String, - }, - - /// List kind:1 text notes by a specific user. - #[command(name = "get-user-notes")] - GetUserNotes { - /// 64-char hex pubkey of the author. - #[arg(long)] - pubkey: String, - /// Maximum number of notes to return (default 50, max 100). - #[arg(long)] - limit: Option, - /// Unix timestamp cursor — return notes created before this time. - #[arg(long)] - before: Option, - }, - - /// Get a user's contact/follow list (kind:3) by hex pubkey. - #[command(name = "get-contact-list")] - GetContactList { - /// 64-char hex pubkey. - #[arg(long)] - pubkey: String, - }, - - // ---- Pack (local) ------------------------------------------------------ - /// Persona pack operations (local, no relay connection needed) - #[command(subcommand)] - Pack(PackCmd), -} - -/// Subcommands for `sprout pack`. -#[derive(Subcommand)] -enum PackCmd { - /// Validate a persona pack directory - Validate { - /// Path to the pack directory - path: String, - }, - /// Inspect a persona pack — show metadata and effective config - Inspect { - /// Path to the pack directory - path: String, - }, -} - -// --------------------------------------------------------------------------- -// 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); - - // Pack commands are local-only — no relay connection needed. - if let Cmd::Pack(ref sub) = cli.command { - return match sub { - PackCmd::Validate { path } => commands::pack::cmd_validate(path), - PackCmd::Inspect { path } => commands::pack::cmd_inspect(path), - }; - } - - // Auth: private key is required for all relay operations. - // The keypair IS the identity — no tokens, no other auth. - let private_key_str = cli.private_key.ok_or_else(|| { - CliError::Auth("SPROUT_PRIVATE_KEY is required (use --private-key or set env var)".into()) - })?; - let keys = Keys::parse(&private_key_str) - .map_err(|e| CliError::Key(format!("invalid SPROUT_PRIVATE_KEY: {e}")))?; - - let client = SproutClient::new(relay_url, keys)?; - - match cli.command { - // ---- Messages ------------------------------------------------------ - Cmd::SendMessage { - channel, - content, - kind, - reply_to, - broadcast, - mentions, - files, - } => { - commands::messages::cmd_send_message( - &client, - commands::messages::SendMessageParams { - channel_id: channel, - content, - kind, - reply_to, - broadcast, - mentions, - files, - }, - ) - .await - } - Cmd::SendDiffMessage { - channel, - diff, - repo, - commit, - file, - parent_commit, - source_branch, - target_branch, - pr, - lang, - description, - reply_to, - } => { - commands::messages::cmd_send_diff_message( - &client, - commands::messages::SendDiffParams { - channel_id: channel, - diff, - repo_url: repo, - commit_sha: commit, - file_path: file, - parent_commit_sha: parent_commit, - source_branch, - target_branch, - pr_number: pr, - language: lang, - description, - reply_to, - }, - ) - .await - } - Cmd::DeleteMessage { event } => { - commands::messages::cmd_delete_message(&client, &event).await - } - Cmd::GetMessages { - channel, - limit, - before, - since, - kinds, - } => { - commands::messages::cmd_get_messages( - &client, - &channel, - limit, - before, - since, - kinds.as_deref(), - ) - .await - } - Cmd::GetThread { - channel, - event, - depth_limit, - limit, - } => { - commands::messages::cmd_get_thread(&client, &channel, &event, depth_limit, limit).await - } - Cmd::Search { query, limit } => { - commands::messages::cmd_search(&client, &query, limit).await - } - Cmd::EditMessage { event, content } => { - commands::messages::cmd_edit_message(&client, &event, &content).await - } - Cmd::VoteOnPost { event, direction } => { - commands::messages::cmd_vote_on_post(&client, &event, &direction).await - } - Cmd::UploadFile { file } => { - let desc = client.upload_file(&file).await?; - println!( - "{}", - serde_json::to_string_pretty(&desc).map_err(|e| CliError::Other(e.to_string()))? - ); - Ok(()) - } - - // ---- Channels ------------------------------------------------------ - Cmd::ListChannels { visibility, member } => { - commands::channels::cmd_list_channels(&client, visibility.as_deref(), Some(member)) - .await - } - Cmd::GetChannel { channel } => commands::channels::cmd_get_channel(&client, &channel).await, - Cmd::CreateChannel { - name, - channel_type, - visibility, - description, - } => { - commands::channels::cmd_create_channel( - &client, - &name, - &channel_type, - &visibility, - description.as_deref(), - ) - .await - } - Cmd::UpdateChannel { - channel, - name, - description, - } => { - commands::channels::cmd_update_channel( - &client, - &channel, - name.as_deref(), - description.as_deref(), - ) - .await - } - Cmd::SetChannelTopic { channel, topic } => { - commands::channels::cmd_set_channel_topic(&client, &channel, &topic).await - } - Cmd::SetChannelPurpose { channel, purpose } => { - commands::channels::cmd_set_channel_purpose(&client, &channel, &purpose).await - } - Cmd::JoinChannel { channel } => { - commands::channels::cmd_join_channel(&client, &channel).await - } - Cmd::LeaveChannel { channel } => { - commands::channels::cmd_leave_channel(&client, &channel).await - } - Cmd::ArchiveChannel { channel } => { - commands::channels::cmd_archive_channel(&client, &channel).await - } - Cmd::UnarchiveChannel { channel } => { - commands::channels::cmd_unarchive_channel(&client, &channel).await - } - Cmd::DeleteChannel { channel } => { - commands::channels::cmd_delete_channel(&client, &channel).await - } - Cmd::ListChannelMembers { channel } => { - commands::channels::cmd_list_channel_members(&client, &channel).await - } - Cmd::AddChannelMember { - channel, - pubkey, - role, - } => { - commands::channels::cmd_add_channel_member(&client, &channel, &pubkey, role.as_deref()) - .await - } - Cmd::RemoveChannelMember { channel, pubkey } => { - commands::channels::cmd_remove_channel_member(&client, &channel, &pubkey).await - } - Cmd::GetCanvas { channel } => commands::channels::cmd_get_canvas(&client, &channel).await, - Cmd::SetCanvas { channel, content } => { - commands::channels::cmd_set_canvas(&client, &channel, &content).await - } - - // ---- Reactions ----------------------------------------------------- - Cmd::AddReaction { event, emoji } => { - commands::reactions::cmd_add_reaction(&client, &event, &emoji).await - } - Cmd::RemoveReaction { event, emoji } => { - commands::reactions::cmd_remove_reaction(&client, &event, &emoji).await - } - Cmd::GetReactions { event } => { - commands::reactions::cmd_get_reactions(&client, &event).await - } - - // ---- DMs ----------------------------------------------------------- - Cmd::ListDms { limit } => commands::dms::cmd_list_dms(&client, limit).await, - Cmd::OpenDm { pubkeys } => commands::dms::cmd_open_dm(&client, &pubkeys).await, - Cmd::AddDmMember { channel, pubkey } => { - commands::dms::cmd_add_dm_member(&client, &channel, &pubkey).await - } - - // ---- Users --------------------------------------------------------- - Cmd::GetUsers { pubkeys } => 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, - - // ---- 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, - - // ---- Social -------------------------------------------------------- - Cmd::PublishNote { content, reply_to } => { - commands::social::cmd_publish_note(&client, &content, reply_to.as_deref()).await - } - Cmd::SetContactList { contacts } => { - commands::social::cmd_set_contact_list(&client, &contacts).await - } - Cmd::GetEvent { event } => commands::social::cmd_get_event(&client, &event).await, - Cmd::GetUserNotes { - pubkey, - limit, - before, - } => commands::social::cmd_get_user_notes(&client, &pubkey, limit, before).await, - Cmd::GetContactList { pubkey } => { - commands::social::cmd_get_contact_list(&client, &pubkey).await - } - - // ---- Pack (local) -------------------------------------------------- - Cmd::Pack(_) => unreachable!("handled above"), - } -} - -// --------------------------------------------------------------------------- -// 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 47 commands. - /// Token commands removed (auth, list-tokens, delete-token, delete-all-tokens). - /// SetChannelAddPolicy removed (relay-side policy now). - /// Cursor removed from get-thread, list-dms. before_id removed from get-user-notes. - #[test] - fn command_inventory_is_47() { - let expected: Vec<&str> = vec![ - "add-channel-member", - "add-dm-member", - "add-reaction", - "approve-step", - "archive-channel", - "create-channel", - "create-workflow", - "delete-channel", - "delete-message", - "delete-workflow", - "edit-message", - "get-canvas", - "get-channel", - "get-contact-list", - "get-event", - "get-feed", - "get-messages", - "get-presence", - "get-reactions", - "get-thread", - "get-user-notes", - "get-users", - "get-workflow", - "get-workflow-runs", - "join-channel", - "leave-channel", - "list-channel-members", - "list-channels", - "list-dms", - "list-workflows", - "open-dm", - "pack", - "publish-note", - "remove-channel-member", - "remove-reaction", - "search", - "send-diff-message", - "send-message", - "set-canvas", - "set-channel-purpose", - "set-channel-topic", - "set-contact-list", - "set-presence", - "set-profile", - "trigger-workflow", - "unarchive-channel", - "update-channel", - "update-workflow", - "upload-file", - "vote-on-post", - ]; - - 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(), - expected.len(), - "Expected {} commands, got {}. Actual: {:?}", - expected.len(), - actual.len(), - actual - ); - assert_eq!(actual, expected, "Command inventory drift detected"); - } + std::process::exit(sprout_cli::run_from_args(std::env::args()).await); } diff --git a/crates/sprout-dev-mcp/Cargo.toml b/crates/sprout-dev-mcp/Cargo.toml index b14ae3bee..538a579cf 100644 --- a/crates/sprout-dev-mcp/Cargo.toml +++ b/crates/sprout-dev-mcp/Cargo.toml @@ -10,6 +10,7 @@ name = "sprout-dev-mcp" path = "src/main.rs" [dependencies] +sprout-cli = { path = "../sprout-cli" } tokio = { workspace = true } tokio-util = { workspace = true } serde = { workspace = true } diff --git a/crates/sprout-dev-mcp/src/main.rs b/crates/sprout-dev-mcp/src/main.rs index 78c298ca6..3a14fdf58 100644 --- a/crates/sprout-dev-mcp/src/main.rs +++ b/crates/sprout-dev-mcp/src/main.rs @@ -35,7 +35,7 @@ impl DevMcp { #[tool( name = "shell", - description = "Run a bash command. Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms capped at 600000. On PATH: rg (prefer over grep; flags: -n -i -l -g -C --files) and tree (flags: -d ; shows line counts)." + description = "Run a bash command. Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms capped at 600000. On PATH: rg (prefer over grep; flags: -n -i -l -g -C --files), tree (flags: -d ; shows line counts), and sprout (Sprout relay CLI — run sprout --help for commands)." )] async fn shell( &self, @@ -132,6 +132,10 @@ async fn main() -> Result<(), Box> { std::process::exit(tree::run(args)); } + if cmd == "sprout" { + std::process::exit(sprout_cli::run_from_args(std::env::args()).await); + } + let cwd = std::env::current_dir()?; let shim = shim::Shim::install()?; let state = Arc::new(shell::SharedState::new(cwd, shim)?); diff --git a/crates/sprout-dev-mcp/src/shell.rs b/crates/sprout-dev-mcp/src/shell.rs index 89f4fee60..ebe6e2db7 100644 --- a/crates/sprout-dev-mcp/src/shell.rs +++ b/crates/sprout-dev-mcp/src/shell.rs @@ -60,10 +60,18 @@ impl SharedState { fn build_bootstrap(cwd: &Path) -> String { let stack = detect_stack(cwd); + let sprout_hint = if std::env::var("SPROUT_RELAY_URL").is_ok() + && std::env::var("SPROUT_PRIVATE_KEY").is_ok() + { + "\nSprout relay configured. Run `sprout --help` to see available commands.\n" + } else { + "" + }; format!( "Working directory: {}\n\ Detected stack: {}\n\ - Pass `workdir` per call rather than `cd`.\n", + Pass `workdir` per call rather than `cd`.\n\ + {sprout_hint}", cwd.display(), stack, ) diff --git a/crates/sprout-dev-mcp/src/shim.rs b/crates/sprout-dev-mcp/src/shim.rs index 2cfee3f82..cc7ff72ee 100644 --- a/crates/sprout-dev-mcp/src/shim.rs +++ b/crates/sprout-dev-mcp/src/shim.rs @@ -20,6 +20,9 @@ impl Shim { let tree_link = dir.path().join("tree"); symlink(&self_exe, &tree_link)?; + let sprout_link = dir.path().join("sprout"); + symlink(&self_exe, &sprout_link)?; + let original = std::env::var_os("PATH").unwrap_or_default(); let mut new_path = std::ffi::OsString::from(dir.path()); if !original.is_empty() {