mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat: embed sprout CLI in dev-mcp, add NIP-OA auth tag support (#525)
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3872,6 +3872,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"similar",
|
||||
"sprout-cli",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Tag>,
|
||||
/// Raw JSON of the auth tag for the `x-auth-tag` HTTP header.
|
||||
auth_tag_json: Option<String>,
|
||||
}
|
||||
|
||||
impl SproutClient {
|
||||
pub fn new(relay_url: String, keys: Keys) -> Result<Self, CliError> {
|
||||
pub fn new(
|
||||
relay_url: String,
|
||||
keys: Keys,
|
||||
auth_tag: Option<Tag>,
|
||||
auth_tag_json: Option<String>,
|
||||
) -> Result<Self, CliError> {
|
||||
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<nostr::Event, CliError> {
|
||||
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();
|
||||
|
||||
@@ -14,11 +14,9 @@ fn parse_uuid(s: &str) -> Result<Uuid, CliError> {
|
||||
|
||||
fn sign_and_submit_builder(
|
||||
builder: nostr::EventBuilder,
|
||||
keys: &nostr::Keys,
|
||||
client: &SproutClient,
|
||||
) -> Result<nostr::Event, CliError> {
|
||||
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(())
|
||||
|
||||
@@ -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<Tag> = 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}");
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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<ContactEntry> = 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}");
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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}");
|
||||
|
||||
946
crates/sprout-cli/src/lib.rs
Normal file
946
crates/sprout-cli/src/lib.rs
Normal file
@@ -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<I, S>(args: I) -> i32
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<std::ffi::OsString> + 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<String>,
|
||||
|
||||
/// NIP-OA auth tag JSON (owner attestation). Injected into every signed event.
|
||||
#[arg(long, env = "SPROUT_AUTH_TAG")]
|
||||
auth_tag: Option<String>,
|
||||
|
||||
#[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<u16>,
|
||||
#[arg(long)]
|
||||
reply_to: Option<String>,
|
||||
#[arg(long, default_value_t = false)]
|
||||
broadcast: bool,
|
||||
#[arg(long = "mention")]
|
||||
mentions: Vec<String>,
|
||||
/// Attach file(s) — uploads and includes as imeta tags
|
||||
#[arg(long = "file")]
|
||||
files: Vec<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
#[arg(long)]
|
||||
parent_commit: Option<String>,
|
||||
#[arg(long)]
|
||||
source_branch: Option<String>,
|
||||
#[arg(long)]
|
||||
target_branch: Option<String>,
|
||||
#[arg(long)]
|
||||
pr: Option<u32>,
|
||||
#[arg(long)]
|
||||
lang: Option<String>,
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
#[arg(long)]
|
||||
reply_to: Option<String>,
|
||||
},
|
||||
/// 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<u32>,
|
||||
#[arg(long)]
|
||||
before: Option<i64>,
|
||||
#[arg(long)]
|
||||
since: Option<i64>,
|
||||
#[arg(long)]
|
||||
kinds: Option<String>,
|
||||
},
|
||||
/// Get a message thread
|
||||
GetThread {
|
||||
#[arg(long)]
|
||||
channel: String,
|
||||
#[arg(long)]
|
||||
event: String,
|
||||
#[arg(long)]
|
||||
depth_limit: Option<u32>,
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
},
|
||||
/// Search messages
|
||||
Search {
|
||||
#[arg(long)]
|
||||
query: String,
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
},
|
||||
/// 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<String>,
|
||||
#[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<String>,
|
||||
},
|
||||
/// Update a channel's name or description
|
||||
UpdateChannel {
|
||||
#[arg(long)]
|
||||
channel: String,
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<u32>,
|
||||
},
|
||||
/// Open a DM with one or more users (1–8 pubkeys)
|
||||
OpenDm {
|
||||
#[arg(long = "pubkey")]
|
||||
pubkeys: Vec<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
/// Update your profile
|
||||
SetProfile {
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
avatar: Option<String>,
|
||||
#[arg(long)]
|
||||
about: Option<String>,
|
||||
#[arg(long)]
|
||||
nip05: Option<String>,
|
||||
},
|
||||
/// 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<u32>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
|
||||
// ---- Feed --------------------------------------------------------------
|
||||
/// Get your activity feed
|
||||
GetFeed {
|
||||
#[arg(long)]
|
||||
since: Option<i64>,
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
#[arg(long)]
|
||||
types: Option<String>,
|
||||
},
|
||||
|
||||
// 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.
|
||||
#[arg(long)]
|
||||
before: Option<i64>,
|
||||
},
|
||||
|
||||
/// 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<bool, CliError> {
|
||||
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<String> = 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");
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
|
||||
#[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<u16>,
|
||||
#[arg(long)]
|
||||
reply_to: Option<String>,
|
||||
#[arg(long, default_value_t = false)]
|
||||
broadcast: bool,
|
||||
#[arg(long = "mention")]
|
||||
mentions: Vec<String>,
|
||||
/// Attach file(s) — uploads and includes as imeta tags
|
||||
#[arg(long = "file")]
|
||||
files: Vec<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
#[arg(long)]
|
||||
parent_commit: Option<String>,
|
||||
#[arg(long)]
|
||||
source_branch: Option<String>,
|
||||
#[arg(long)]
|
||||
target_branch: Option<String>,
|
||||
#[arg(long)]
|
||||
pr: Option<u32>,
|
||||
#[arg(long)]
|
||||
lang: Option<String>,
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
#[arg(long)]
|
||||
reply_to: Option<String>,
|
||||
},
|
||||
/// 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<u32>,
|
||||
#[arg(long)]
|
||||
before: Option<i64>,
|
||||
#[arg(long)]
|
||||
since: Option<i64>,
|
||||
#[arg(long)]
|
||||
kinds: Option<String>,
|
||||
},
|
||||
/// Get a message thread
|
||||
GetThread {
|
||||
#[arg(long)]
|
||||
channel: String,
|
||||
#[arg(long)]
|
||||
event: String,
|
||||
#[arg(long)]
|
||||
depth_limit: Option<u32>,
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
},
|
||||
/// Search messages
|
||||
Search {
|
||||
#[arg(long)]
|
||||
query: String,
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
},
|
||||
/// 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<String>,
|
||||
#[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<String>,
|
||||
},
|
||||
/// Update a channel's name or description
|
||||
UpdateChannel {
|
||||
#[arg(long)]
|
||||
channel: String,
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<u32>,
|
||||
},
|
||||
/// Open a DM with one or more users (1–8 pubkeys)
|
||||
OpenDm {
|
||||
#[arg(long = "pubkey")]
|
||||
pubkeys: Vec<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
/// Update your profile
|
||||
SetProfile {
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
avatar: Option<String>,
|
||||
#[arg(long)]
|
||||
about: Option<String>,
|
||||
#[arg(long)]
|
||||
nip05: Option<String>,
|
||||
},
|
||||
/// 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<u32>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
|
||||
// ---- Feed --------------------------------------------------------------
|
||||
/// Get your activity feed
|
||||
GetFeed {
|
||||
#[arg(long)]
|
||||
since: Option<i64>,
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
#[arg(long)]
|
||||
types: Option<String>,
|
||||
},
|
||||
|
||||
// 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.
|
||||
#[arg(long)]
|
||||
before: Option<i64>,
|
||||
},
|
||||
|
||||
/// 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<bool, CliError> {
|
||||
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<String> = 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);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 <glob> -C <n> --files) and tree (flags: -d <depth>; 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 <glob> -C <n> --files), tree (flags: -d <depth>; 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<dyn std::error::Error>> {
|
||||
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)?);
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user