feat: embed sprout CLI in dev-mcp, add NIP-OA auth tag support (#525)

This commit is contained in:
tlongwell-block
2026-05-11 06:57:34 -04:00
committed by GitHub
parent 082414bf3f
commit 91f16e326f
17 changed files with 1069 additions and 1038 deletions

1
Cargo.lock generated
View File

@@ -3872,6 +3872,7 @@ dependencies = [
"serde",
"serde_json",
"similar",
"sprout-cli",
"tempfile",
"tokio",
"tokio-util",

View File

@@ -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"

View File

@@ -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();

View File

@@ -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(())

View File

@@ -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}");

View File

@@ -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}");

View File

@@ -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).

View File

@@ -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}");

View File

@@ -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}");

View File

@@ -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}");

View File

@@ -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}");

View 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");
}
}

View File

@@ -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);
}

View File

@@ -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 }

View File

@@ -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)?);

View File

@@ -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,
)

View File

@@ -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() {