Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/sprout-acp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2198,6 +2198,16 @@ fn build_mcp_servers(config: &Config) -> Vec<McpServer> {
value: token.clone(),
});
}
// Forward SPROUT_TOOLSETS so the MCP server enables the
// same toolsets the operator configured for this harness.
if let Ok(ts) = std::env::var("SPROUT_TOOLSETS") {
if !ts.is_empty() {
env.push(EnvVar {
name: "SPROUT_TOOLSETS".into(),
value: ts,
});
}
}
env
},
}]
Expand Down
1 change: 1 addition & 0 deletions crates/sprout-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ pub mod dms;
pub mod feed;
pub mod messages;
pub mod reactions;
pub mod social;
pub mod users;
pub mod workflows;
131 changes: 131 additions & 0 deletions crates/sprout-cli/src/commands/social.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
use nostr::EventId;
use serde::Deserialize;

use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::validate_hex64;

/// Each command module defines its own `require_keys!` macro.
macro_rules! require_keys {
($client:expr) => {
$client.keys().ok_or_else(|| {
CliError::Key(
"private key required for write operations (set SPROUT_PRIVATE_KEY)".into(),
)
})?
};
}

/// Per-module helper (same pattern as messages.rs).
fn parse_event_id(hex: &str) -> Result<EventId, CliError> {
EventId::parse(hex).map_err(|e| CliError::Usage(format!("invalid event ID: {e}")))
}

/// A single contact entry (CLI-local, not from sprout-sdk).
#[derive(Debug, Deserialize)]
pub struct ContactEntry {
pub pubkey: String,
#[serde(default)]
pub relay_url: Option<String>,
#[serde(default)]
pub petname: Option<String>,
}

pub async fn cmd_publish_note(
client: &SproutClient,
content: &str,
reply_to: Option<&str>,
) -> Result<(), CliError> {
if let Some(r) = reply_to {
validate_hex64(r)?;
}

let keys = require_keys!(client);
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 resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}

pub async fn cmd_set_contact_list(
client: &SproutClient,
contacts_json: &str,
) -> Result<(), CliError> {
let keys = require_keys!(client);
let entries: Vec<ContactEntry> = serde_json::from_str(contacts_json)
.map_err(|e| CliError::Usage(format!("invalid contacts JSON: {e}")))?;

let contacts: Vec<(&str, Option<&str>, Option<&str>)> = entries
.iter()
.map(|c| {
(
c.pubkey.as_str(),
c.relay_url.as_deref(),
c.petname.as_deref(),
)
})
.collect();

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 resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}

pub async fn cmd_get_event(client: &SproutClient, event_id: &str) -> Result<(), CliError> {
validate_hex64(event_id)?;
let path = format!("/api/events/{event_id}");
client.run_get(&path).await
}

pub async fn cmd_get_user_notes(
client: &SproutClient,
pubkey: &str,
limit: Option<u32>,
before: Option<i64>,
before_id: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pubkey)?;
if let Some(bid) = before_id {
validate_hex64(bid)?;
}
if before_id.is_some() && before.is_none() {
return Err(CliError::Usage("before_id requires before".to_string()));
}
let mut path = format!("/api/users/{pubkey}/notes");
let mut params = vec![];
if let Some(l) = limit {
params.push(format!("limit={l}"));
}
if let Some(b) = before {
params.push(format!("before={b}"));
}
if let Some(bid) = before_id {
params.push(format!("before_id={bid}"));
}
if !params.is_empty() {
path.push('?');
path.push_str(&params.join("&"));
}
client.run_get(&path).await
}

pub async fn cmd_get_contact_list(client: &SproutClient, pubkey: &str) -> Result<(), CliError> {
validate_hex64(pubkey)?;
let path = format!("/api/users/{pubkey}/contact-list");
client.run_get(&path).await
}
96 changes: 92 additions & 4 deletions crates/sprout-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,62 @@ enum Cmd {
},
/// Delete all your API tokens
DeleteAllTokens,

// 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<String>,
},

/// 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<u32>,
/// Unix timestamp cursor — return notes created before this time.
/// Use with --before-id for stable composite cursor pagination.
#[arg(long)]
before: Option<i64>,
/// Hex event ID cursor for composite keyset pagination. Use together with
/// --before to avoid skipping same-second events. Pass the before_id value
/// from the previous page's next_cursor response.
#[arg(long)]
before_id: Option<String>,
},

/// 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,
},
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -728,6 +784,33 @@ async fn run(cli: Cli) -> Result<(), CliError> {
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,
before_id,
} => {
commands::social::cmd_get_user_notes(
&client,
&pubkey,
limit,
before,
before_id.as_deref(),
)
.await
}
Cmd::GetContactList { pubkey } => {
commands::social::cmd_get_contact_list(&client, &pubkey).await
}

// ---- Auth & Tokens -------------------------------------------------
Cmd::Auth => unreachable!("handled above"),
Cmd::ListTokens => commands::auth::cmd_list_tokens(&client).await,
Expand Down Expand Up @@ -779,10 +862,10 @@ mod tests {
assert!(super::parse_bool_flag("--approved", "").is_err());
}

/// Parity: the CLI exposes exactly the expected 48 commands.
/// Parity: the CLI exposes exactly the expected 53 commands.
/// If a command is added or removed, this test forces a conscious update.
#[test]
fn command_inventory_is_48() {
fn command_inventory_is_53() {
let expected: Vec<&str> = vec![
"add-channel-member",
"add-dm-member",
Expand All @@ -800,11 +883,14 @@ mod tests {
"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",
Expand All @@ -816,6 +902,7 @@ mod tests {
"list-tokens",
"list-workflows",
"open-dm",
"publish-note",
"remove-channel-member",
"remove-reaction",
"search",
Expand All @@ -825,6 +912,7 @@ mod tests {
"set-channel-add-policy",
"set-channel-purpose",
"set-channel-topic",
"set-contact-list",
"set-presence",
"set-profile",
"trigger-workflow",
Expand All @@ -844,8 +932,8 @@ mod tests {

assert_eq!(
actual.len(),
48,
"Expected 48 commands, got {}. Actual: {:?}",
53,
"Expected 53 commands, got {}. Actual: {:?}",
actual.len(),
actual
);
Expand Down
Loading
Loading