Fix CLI output normalization and wrong Nostr kinds (MCP parity) (#612)

This commit is contained in:
Will Pfleger
2026-05-22 10:32:20 -04:00
committed by GitHub
parent 741c09d9fe
commit 6dcb04bcd9
13 changed files with 777 additions and 178 deletions

View File

@@ -52,8 +52,8 @@ See CONTRIBUTING.md for full setup details and dependency requirements.
## Quality Gates
Run `just ci` before every PR. It runs: Rust `fmt` + `clippy`, desktop lint
(Biome), unit tests, desktop build, and Tauri check. All must pass.
Run `just ci` before every PR — it runs `fmt` + `clippy` + desktop lint +
unit tests + builds. Clippy passing does not mean fmt passes; run both.
Run `just test` for integration tests if you touched `sprout-relay`,
`sprout-db`, or `sprout-auth` — these require a running Postgres and Redis.
@@ -113,6 +113,19 @@ check existing reply handlers for the pattern.
---
## Agent CLI (`sprout-cli`)
`sprout` is the agent-first CLI replacing `sprout-mcp`. Auth env vars
(`SPROUT_RELAY_URL`, `SPROUT_PRIVATE_KEY`) are auto-injected by the ACP
harness into managed agent subprocesses.
All reads return sig-stripped JSON arrays; all writes return
`{event_id, accepted, message}`; creates add the entity ID. Exit codes:
0=ok, 1=input error, 3=auth missing. See `crates/sprout-cli/TESTING.md`
for the full live-testing runbook.
---
## Testing
```bash
@@ -136,6 +149,15 @@ See [TESTING.md](TESTING.md) for the full multi-agent E2E guide.
---
## Common Gotchas
1. **Kind `39000` for channel metadata, not `41`** — kind 41 is NIP-01 (unused). All kinds defined in `sprout-core/src/kind.rs`.
2. **Relay queries must specify `kinds`** — omitting `kinds` triggers the p-gate (403). Always include explicit kind filters.
3. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command.
4. **Desktop fmt check fails in worktrees** — run `just desktop-tauri-fmt-check` from the main checkout. CI is unaffected.
---
## Desktop App
The desktop app is Tauri 2 + React 19 + Vite + Tailwind CSS. Features are

View File

@@ -140,18 +140,8 @@ export SPROUT_PRIVATE_KEY=$(echo "$GEN" | awk '/Secret key:/ {print $3}')
PUBKEY=$(echo "$GEN" | awk '/Public key:/ {print $3}')
echo "pubkey: $PUBKEY"
# Create a channel (the CLI generates the UUID client-side and embeds it in
# the kind:9007 event; it does NOT return the UUID in the response yet)
sprout channels create --name "smoke-$$" --type stream --visibility open
# Find your new channel's UUID. kind:39002 (channel metadata) lists you as
# owner; the channel UUID is in the `d` tag.
CHANNEL=$(sprout channels list --member \
| jq -r --arg pk "$PUBKEY" '
.[]
| select(any(.tags[]; .[0]=="p" and .[1]==$pk and .[3]=="owner"))
| (.tags[] | select(.[0]=="d") | .[1])' \
| head -1)
# Create a channel — the UUID is returned in the response
CHANNEL=$(sprout channels create --name "smoke-$$" --type stream --visibility open | jq -r '.channel_id')
echo "channel: $CHANNEL"
# Send a message and read it back

View File

@@ -120,43 +120,57 @@ earlier ones create resources that later ones need.
sprout channels create --name "test-stream" --type stream --visibility open \
--description "CLI test channel" | jq .
# Save the channel ID:
CHANNEL_ID=$(sprout channels create --name "test-cli" --type stream --visibility open | jq -r '.id')
CHANNEL_ID=$(sprout channels create --name "test-cli" --type stream --visibility open | jq -r '.channel_id')
# Expected: {"event_id":"...","accepted":true,"message":"...","channel_id":"<uuid>"}
# channels create (forum) — needed for messages vote later
FORUM_ID=$(sprout channels create --name "test-forum" --type forum --visibility open | jq -r '.id')
FORUM_ID=$(sprout channels create --name "test-forum" --type forum --visibility open | jq -r '.channel_id')
# channels list
sprout channels list | jq .
# Expected: [{"channel_id":"...","name":"...","description":"...","created_at":N}]
sprout channels list --visibility open | jq .
sprout channels list --member | jq .
# channels get
sprout channels get --channel "$CHANNEL_ID" | jq .
# Expected: {"channel_id":"...","name":"...","description":"...","created_at":N,"pubkey":"..."} or null
# channels update
sprout channels update --channel "$CHANNEL_ID" --name "test-cli-updated" \
--description "Updated" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels topic
sprout channels topic --channel "$CHANNEL_ID" --topic "Test topic" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels purpose
sprout channels purpose --channel "$CHANNEL_ID" --purpose "Testing" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels join (may already be a member from create)
sprout channels join --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels leave
# NOTE: Fails with 400 "cannot remove the last owner" if this identity is the
# sole owner (which it is after channels create). To test leave successfully,
# first add-member a second pubkey as owner. The relay enforces ≥1 owner.
sprout channels leave --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."} (or 400 if last owner)
# Re-join so we can send messages
sprout channels join --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels archive (requires admin:channels scope)
sprout channels archive --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels unarchive
sprout channels unarchive --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
```
### 6.2 Canvas
@@ -169,7 +183,8 @@ sprout canvas set --channel "$CHANNEL_ID" --content "# Test Canvas" | jq .
echo "# Canvas from stdin" | sprout canvas set --channel "$CHANNEL_ID" --content - | jq .
# canvas get
sprout canvas get --channel "$CHANNEL_ID" | jq .
sprout canvas get --channel "$CHANNEL_ID"
# Expected: raw markdown string, or: null
```
### 6.3 Messages
@@ -178,13 +193,13 @@ sprout canvas get --channel "$CHANNEL_ID" | jq .
# messages send
MSG=$(sprout messages send --channel "$CHANNEL_ID" --content "Hello from CLI test" | jq .)
echo "$MSG"
EVENT_ID=$(echo "$MSG" | jq -r '.id // .event_id')
EVENT_ID=$(echo "$MSG" | jq -r '.event_id')
# messages send with reply + broadcast
REPLY=$(sprout messages send --channel "$CHANNEL_ID" --content "Reply" \
--reply-to "$EVENT_ID" --broadcast | jq .)
echo "$REPLY"
REPLY_ID=$(echo "$REPLY" | jq -r '.id // .event_id')
REPLY_ID=$(echo "$REPLY" | jq -r '.event_id')
# messages send with mentions
sprout messages send --channel "$CHANNEL_ID" --content "Hey @someone" \
@@ -254,13 +269,14 @@ echo "diff content" | sprout messages send-diff \
```bash
# Send a message to react to
REACT_MSG=$(sprout messages send --channel "$CHANNEL_ID" --content "React to this")
REACT_ID=$(echo "$REACT_MSG" | jq -r '.id // .event_id')
REACT_ID=$(echo "$REACT_MSG" | jq -r '.event_id')
# reactions add
sprout reactions add --event "$REACT_ID" --emoji "👍" | jq .
# reactions get
sprout reactions get --event "$REACT_ID" | jq .
# Expected: {"reactions":[{"emoji":"...","count":N,"pubkeys":["..."]}]}
# reactions remove
sprout reactions remove --event "$REACT_ID" --emoji "👍" | jq .
@@ -271,16 +287,18 @@ sprout reactions remove --event "$REACT_ID" --emoji "👍" | jq .
```bash
# dms list
sprout dms list | jq .
# Expected: [{"dm_id":"...","participants":["..."],"created_at":N}]
# dms open (needs a real pubkey — use your own or a test one)
# Get your own pubkey first:
MY_PUBKEY=$(sprout users get | jq -r '.pubkey // .[0].pubkey // empty')
MY_PUBKEY=$(sprout users get | jq -r '.[0].pubkey // empty')
echo "My pubkey: $MY_PUBKEY"
# dms open with a synthetic pubkey (relay will create the user)
DM_RESULT=$(sprout dms open --pubkey "0000000000000000000000000000000000000000000000000000000000000001")
echo "$DM_RESULT" | jq .
DM_ID=$(echo "$DM_RESULT" | jq -r '.channel_id // .id // empty')
# Expected: {"event_id":"...","accepted":true,"message":"...","dm_id":"<uuid>"}
DM_ID=$(echo "$DM_RESULT" | jq -r '.dm_id')
# dms add-member (requires messages:write scope — NOT admin:channels)
sprout dms add-member --channel "$DM_ID" \
@@ -292,6 +310,7 @@ sprout dms add-member --channel "$DM_ID" \
```bash
# users get — own profile (0 pubkeys)
sprout users get | jq .
# Expected: [{...profile...}] — always returns an array, even for single results
# users get — single pubkey
sprout users get --pubkey "$MY_PUBKEY" | jq .
@@ -309,6 +328,7 @@ sprout users presence --pubkeys "$MY_PUBKEY" | jq .
sprout users set-presence --status online | jq .
sprout users set-presence --status away | jq .
sprout users set-presence --status offline | jq .
# Note: set-presence may fail — kind:20001 is ephemeral and rejected by the HTTP bridge
```
### 6.8 Channel Members (add/remove require admin:channels)
@@ -321,6 +341,7 @@ sprout channels add-member --channel "$CHANNEL_ID" \
# channels members
sprout channels members --channel "$CHANNEL_ID" | jq .
# Expected: [{"pubkey":"...","role":"..."}]
# channels remove-member
sprout channels remove-member --channel "$CHANNEL_ID" \
@@ -343,16 +364,17 @@ steps:
action: send_message
text: "Hello from workflow"' | jq .)
echo "$WF"
WF_ID=$(echo "$WF" | jq -r '.id')
WF_ID=$(echo "$WF" | jq -r '.workflow_id')
# workflows list
sprout workflows list --channel "$CHANNEL_ID" | jq .
# workflows get
sprout workflows get --workflow "$WF_ID" | jq .
# Expected: {"workflow_id":"...","content":"<yaml>","created_at":N,"pubkey":"..."} or null
# workflows update
sprout workflows update --workflow "$WF_ID" \
# workflows update (requires --channel)
sprout workflows update --channel "$CHANNEL_ID" --workflow "$WF_ID" \
--yaml 'name: test-wf-updated
trigger:
on: webhook
@@ -362,10 +384,14 @@ steps:
text: "Updated"' | jq .
# workflows trigger
# NOTE: May return 400 "workflow not found" — the relay indexes workflow
# definitions into a DB table asynchronously. If the definition event hasn't
# been indexed yet, the trigger handler won't find it.
sprout workflows trigger --workflow "$WF_ID" | jq .
# workflows runs
sprout workflows runs --workflow "$WF_ID" | jq .
# Expected: [] — relay stores runs in DB, not as Nostr events; empty is normal
# workflows approve — requires a workflow run waiting for approval
# This is hard to test ad-hoc without a workflow that has an approval gate.
@@ -383,6 +409,7 @@ sprout workflows delete --workflow "$WF_ID" | jq .
```bash
sprout feed get | jq .
sprout feed get --limit 5 | jq .
# Expected: [{id,pubkey,kind,content,created_at,tags}] — sig-stripped, sorted newest-first
```
### 6.11 Forum & Voting
@@ -392,7 +419,7 @@ sprout feed get --limit 5 | jq .
FORUM_POST=$(sprout messages send --channel "$FORUM_ID" \
--content "Forum post for vote testing" --kind 45001 | jq .)
echo "$FORUM_POST"
FORUM_EVENT_ID=$(echo "$FORUM_POST" | jq -r '.id // .event_id')
FORUM_EVENT_ID=$(echo "$FORUM_POST" | jq -r '.event_id')
# messages vote (up)
sprout messages vote --event "$FORUM_EVENT_ID" --direction up | jq .
@@ -456,9 +483,9 @@ sprout messages delete --event "not-hex" 2>&1; echo "exit: $?"
# stderr: {"error":"user_error","message":"must be a 64-character hex string: not-hex"}
# exit: 1
# Exit 1: Invalid --type value
# Exit 1: Invalid --type value (clap validates the enum — multi-line error)
sprout channels create --name x --type invalid --visibility open 2>&1; echo "exit: $?"
# stderr: {"error":"user_error","message":"--type must be 'stream' or 'forum' (got: invalid)"}
# stderr: {"error":"user_error","message":"error: invalid value 'invalid' for '--type <CHANNEL_TYPE>'\n [possible values: stream, forum]\n..."}
# exit: 1
# Exit 1: Invalid --direction value
@@ -473,13 +500,13 @@ sprout users set-profile 2>&1; echo "exit: $?"
# Exit 3: No auth configured
env -u SPROUT_PRIVATE_KEY \
cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?"
# stderr: {"error":"auth_error","message":"SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"}
# stderr: {"error":"auth_error","message":"auth error: SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"}
# exit: 3
# Exit 2: Non-existent channel (valid UUID)
sprout channels get --channel "00000000-0000-0000-0000-000000000000" 2>&1; echo "exit: $?"
# stderr: {"error":"relay_error","message":"..."}
# exit: 2
# Not-found returns null, not an error (exit 0)
sprout channels get --channel "00000000-0000-0000-0000-000000000000"
# stdout: null
# exit: 0
```
---
@@ -496,7 +523,7 @@ SPROUT_PRIVATE_KEY="nsec1..." sprout channels list | jq .
# No auth → exit 3
env -u SPROUT_PRIVATE_KEY \
cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?"
# stderr: {"error":"auth_error","message":"SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"}
# stderr: {"error":"auth_error","message":"auth error: SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"}
# exit: 3
```

View File

@@ -220,11 +220,16 @@ impl SproutClient {
/// `filter` is a Nostr filter object (will be wrapped in an array).
/// Returns the raw JSON response (array of events).
pub async fn query(&self, filter: &serde_json::Value) -> Result<String, CliError> {
self.query_multi(std::slice::from_ref(filter)).await
}
/// Execute a one-shot query with multiple filters via the HTTP bridge.
/// Each filter is ORed by the relay (standard Nostr REQ behavior).
pub async fn query_multi(&self, filters: &[serde_json::Value]) -> Result<String, CliError> {
let url = format!("{}/query", self.relay_url);
let body_bytes = serde_json::to_vec(&[filter])
let body_bytes = serde_json::to_vec(filters)
.map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?;
let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?;
let req = self
.http
.post(&url)
@@ -232,7 +237,6 @@ impl SproutClient {
.header("Content-Type", "application/json")
.body(body_bytes);
let resp = self.with_auth_tag(req).send().await?;
self.handle_response(resp).await
}
@@ -404,6 +408,15 @@ impl SproutClient {
.map(|s| s.to_string())
})
.unwrap_or(body);
if status == 403 && std::env::var("SPROUT_AUTH_TAG").is_ok() {
let message = format!(
"{message} (SPROUT_AUTH_TAG is set — it may be stale or revoked; try unsetting it)"
);
return Err(CliError::Relay {
status,
body: message,
});
}
return Err(CliError::Relay {
status,
body: message,
@@ -425,3 +438,119 @@ pub fn normalize_relay_url(url: &str) -> String {
.trim_end_matches('/')
.to_string()
}
// ---------------------------------------------------------------------------
// Output normalization helpers
// ---------------------------------------------------------------------------
/// Normalize raw event JSON array into consistent shape.
/// Each event becomes: {id, pubkey, kind, content, created_at, tags}
pub fn normalize_events(events: &[serde_json::Value]) -> String {
let normalized: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""),
"pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""),
"kind": e.get("kind").and_then(|v| v.as_u64()).unwrap_or(0),
"content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
"tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])),
})
})
.collect();
serde_json::to_string(&normalized).unwrap_or_default()
}
/// Extract the d-tag value from a Nostr event JSON object.
pub fn extract_d_tag(event: &serde_json::Value) -> String {
event
.get("tags")
.and_then(|t| t.as_array())
.and_then(|tags| {
tags.iter().find(|t| {
t.as_array()
.and_then(|a| a.first())
.and_then(|v| v.as_str())
== Some("d")
})
})
.and_then(|t| t.as_array())
.and_then(|a| a.get(1))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
/// Extract a named tag's value from a Nostr event JSON object.
/// Finds the first tag whose first element matches `key` and returns the second element.
pub fn extract_tag_value(event: &serde_json::Value, key: &str) -> String {
event
.get("tags")
.and_then(|t| t.as_array())
.and_then(|tags| {
tags.iter().find(|t| {
t.as_array()
.and_then(|a| a.first())
.and_then(|v| v.as_str())
== Some(key)
})
})
.and_then(|t| t.as_array())
.and_then(|a| a.get(1))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
/// Extract all p-tags into [{pubkey, role}] from a Nostr event JSON object.
pub fn extract_p_tags(event: &serde_json::Value) -> Vec<serde_json::Value> {
event
.get("tags")
.and_then(|t| t.as_array())
.map(|tags| {
tags.iter()
.filter(|t| {
t.as_array()
.and_then(|a| a.first())
.and_then(|v| v.as_str())
== Some("p")
})
.map(|t| {
let a = t.as_array().unwrap();
serde_json::json!({
"pubkey": a.get(1).and_then(|v| v.as_str()).unwrap_or(""),
"role": a.get(3).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).unwrap_or("member"),
})
})
.collect()
})
.unwrap_or_default()
}
/// Print a create-command response, injecting the generated entity ID.
pub fn print_create_response(resp: &str, id_key: &str, id_val: &str) {
let mut v: serde_json::Value = serde_json::from_str(resp).unwrap_or(serde_json::json!({}));
v[id_key] = serde_json::json!(id_val);
if v.get("accepted").is_none() {
v["accepted"] = serde_json::json!(true);
}
println!("{v}");
}
/// Normalize a relay write-response into a consistent JSON object.
/// Relay returns: {"event_id": "...", "accepted": true, "message": "..."}
/// Falls back to raw text if parsing fails.
pub fn normalize_write_response(raw: &str) -> String {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw) {
if v.get("event_id").is_some() || v.get("accepted").is_some() {
return serde_json::json!({
"event_id": v.get("event_id").and_then(|v| v.as_str()).unwrap_or(""),
"accepted": v.get("accepted").and_then(|v| v.as_bool()).unwrap_or(false),
"message": v.get("message").and_then(|v| v.as_str()).unwrap_or(""),
})
.to_string();
}
}
raw.to_string()
}

View File

@@ -1,6 +1,9 @@
use uuid::Uuid;
use crate::client::SproutClient;
use crate::client::{
extract_d_tag, extract_p_tags, extract_tag_value, normalize_write_response,
print_create_response, SproutClient,
};
use crate::error::CliError;
use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid};
@@ -8,36 +11,123 @@ use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid};
// Read commands — POST /query
// ---------------------------------------------------------------------------
fn extract_channel_metadata(e: &serde_json::Value) -> serde_json::Value {
serde_json::json!({
"channel_id": extract_d_tag(e),
"name": extract_tag_value(e, "name"),
"description": extract_tag_value(e, "about"),
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
})
}
pub async fn cmd_list_channels(
client: &SproutClient,
_visibility: Option<&str>,
_member: Option<bool>,
visibility: Option<&str>,
member: Option<bool>,
limit: Option<u32>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
// Query kind:39002 channel metadata events.
// If member=true, filter by #p tag containing our pubkey.
let my_pk = client.keys().public_key().to_hex();
let mut filter = serde_json::json!({
"kinds": [39002]
});
// When member filter is requested, query channels where we're a participant
if _member == Some(true) {
filter["#p"] = serde_json::json!([my_pk]);
}
// Visibility filtering is done client-side from the returned events
let resp = client.query(&filter).await?;
println!("{resp}");
let effective_limit = limit.unwrap_or(500);
let raw = if member == Some(true) {
// Step 1: find channel IDs where we're a member (kind:39002)
let my_pk = client.keys().public_key().to_hex();
let member_filter = serde_json::json!({
"kinds": [39002],
"#p": [my_pk],
"limit": effective_limit
});
let member_resp = client.query(&member_filter).await?;
let member_events: Vec<serde_json::Value> =
serde_json::from_str(&member_resp).unwrap_or_default();
let channel_ids: Vec<String> = member_events
.iter()
.map(extract_d_tag)
.filter(|id| !id.is_empty())
.collect();
if channel_ids.is_empty() {
println!("[]");
return Ok(());
}
// Step 2: fetch kind:39000 metadata for those channels
let metadata_filter = serde_json::json!({
"kinds": [39000],
"#d": channel_ids,
"limit": effective_limit
});
client.query(&metadata_filter).await?
} else {
let filter = serde_json::json!({
"kinds": [39000],
"limit": effective_limit
});
client.query(&filter).await?
};
let events: Vec<serde_json::Value> = serde_json::from_str(&raw).unwrap_or_default();
let channels: Vec<serde_json::Value> = events
.iter()
.filter(|e| {
if let Some(vis) = visibility {
// NIP-29: relay emits ["public"] or ["private"] single-element tags
let nip29_tag = match vis {
"open" => "public",
_ => vis,
};
e.get("tags")
.and_then(|t| t.as_array())
.map(|tags| {
tags.iter().any(|tag| {
tag.as_array()
.map(|a| {
a.len() == 1
&& a.first().and_then(|v| v.as_str()) == Some(nip29_tag)
})
.unwrap_or(false)
})
})
.unwrap_or(false)
} else {
true
}
})
.map(extract_channel_metadata)
.collect();
let output = match format {
crate::OutputFormat::Compact => {
let compact: Vec<serde_json::Value> = channels
.iter()
.map(|c| {
serde_json::json!({
"channel_id": c.get("channel_id").cloned().unwrap_or_default(),
"name": c.get("name").cloned().unwrap_or_default(),
})
})
.collect();
serde_json::to_string(&compact).unwrap_or_default()
}
crate::OutputFormat::Json => serde_json::to_string(&channels).unwrap_or_default(),
};
println!("{output}");
Ok(())
}
pub async fn cmd_get_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
// Query kind:39002 with #h tag matching the channel UUID
let filter = serde_json::json!({
"kinds": [39002],
"#h": [channel_id]
"kinds": [39000],
"#d": [channel_id],
"limit": 1
});
let resp = client.query(&filter).await?;
println!("{resp}");
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
if let Some(e) = events.first() {
let mut normalized = extract_channel_metadata(e);
normalized["pubkey"] =
serde_json::json!(e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""));
println!("{normalized}");
} else {
println!("null");
}
Ok(())
}
@@ -46,25 +136,36 @@ pub async fn cmd_list_channel_members(
channel_id: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
// Query kind:39002 channel metadata — members are in the p-tags
let filter = serde_json::json!({
"kinds": [39002],
"#h": [channel_id]
"#d": [channel_id],
"limit": 1
});
let resp = client.query(&filter).await?;
println!("{resp}");
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let members = events.first().map(extract_p_tags).unwrap_or_default();
let output = serde_json::to_string(&members).unwrap_or_default();
println!("{output}");
Ok(())
}
pub async fn cmd_get_canvas(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
// Canvas is kind:40100 with #h tag
let filter = serde_json::json!({
"kinds": [40100],
"#h": [channel_id]
});
let resp = client.query(&filter).await?;
println!("{resp}");
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
if let Some(content) = events
.first()
.and_then(|e| e.get("content"))
.and_then(|c| c.as_str())
{
println!("{content}");
} else {
println!("null");
}
Ok(())
}
@@ -114,7 +215,7 @@ pub async fn cmd_create_channel(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
print_create_response(&resp, "channel_id", &channel_uuid.to_string());
Ok(())
}
@@ -136,7 +237,7 @@ pub async fn cmd_update_channel(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -152,7 +253,7 @@ pub async fn cmd_set_channel_topic(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -168,7 +269,7 @@ pub async fn cmd_set_channel_purpose(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -180,7 +281,7 @@ pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -192,7 +293,7 @@ pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Resul
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -204,7 +305,7 @@ pub async fn cmd_archive_channel(client: &SproutClient, channel_id: &str) -> Res
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -219,7 +320,7 @@ pub async fn cmd_unarchive_channel(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -231,7 +332,7 @@ pub async fn cmd_delete_channel(client: &SproutClient, channel_id: &str) -> Resu
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -262,7 +363,7 @@ pub async fn cmd_add_channel_member(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -279,7 +380,7 @@ pub async fn cmd_remove_channel_member(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -296,7 +397,7 @@ pub async fn cmd_set_canvas(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -304,12 +405,20 @@ pub async fn cmd_set_canvas(
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::ChannelsCmd, client: &SproutClient) -> Result<(), CliError> {
pub async fn dispatch(
cmd: crate::ChannelsCmd,
client: &SproutClient,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
use crate::ChannelsCmd;
match cmd {
ChannelsCmd::List { visibility, member } => {
ChannelsCmd::List {
visibility,
member,
limit,
} => {
let vis_str = visibility.as_ref().map(|v| v.to_string());
cmd_list_channels(client, vis_str.as_deref(), Some(member)).await
cmd_list_channels(client, vis_str.as_deref(), Some(member), limit, format).await
}
ChannelsCmd::Get { channel } => cmd_get_channel(client, &channel).await,
ChannelsCmd::Create {

View File

@@ -1,32 +1,94 @@
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::{parse_uuid, sdk_err};
use uuid::Uuid;
/// List DM conversations by querying kind:41010 (DM open) events authored by us.
use crate::client::{extract_d_tag, normalize_write_response, SproutClient};
use crate::error::CliError;
use crate::validate::{parse_uuid, sdk_err, validate_hex64};
/// List DM conversations by querying kind:41001 (relay-confirmed DMs) filtered by our pubkey.
pub async fn cmd_list_dms(client: &SproutClient, limit: Option<u32>) -> Result<(), CliError> {
let my_pk = client.keys().public_key().to_hex();
let limit = limit.unwrap_or(50).min(200);
let filter = serde_json::json!({
"kinds": [41010],
"authors": [my_pk],
"kinds": [41001],
"#p": [my_pk],
"limit": limit
});
let resp = client.query(&filter).await?;
println!("{resp}");
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let dms: Vec<serde_json::Value> = events
.iter()
.map(|e| {
let dm_id = extract_d_tag(e);
let participants: Vec<String> = e
.get("tags")
.and_then(|t| t.as_array())
.map(|tags| {
tags.iter()
.filter_map(|tag| {
let arr = tag.as_array()?;
if arr.first()?.as_str()? == "p" {
arr.get(1)?.as_str().map(|s| s.to_string())
} else {
None
}
})
.collect()
})
.unwrap_or_default();
serde_json::json!({
"dm_id": dm_id,
"participants": participants,
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
})
})
.collect();
let output = serde_json::to_string(&dms).unwrap_or_default();
println!("{output}");
Ok(())
}
/// Open a DM with one or more users — sign and submit a kind:41010 event.
/// Open a DM with one or more users — sign and submit a kind:41010 event with a d-tag.
pub async fn cmd_open_dm(client: &SproutClient, pubkeys: &[String]) -> Result<(), CliError> {
if pubkeys.is_empty() || pubkeys.len() > 8 {
return Err(CliError::Usage("--pubkey: must provide 1–8 pubkeys".into()));
return Err(CliError::Usage("--pubkey: must provide 1-8 pubkeys".into()));
}
for pk in pubkeys {
validate_hex64(pk)?;
}
let dm_id = Uuid::new_v4().to_string();
let refs: Vec<&str> = pubkeys.iter().map(String::as_str).collect();
let builder = sprout_sdk::build_dm_open(&refs).map_err(sdk_err)?;
// build_dm_open doesn't accept a d-tag, so we build the event manually
// using the SDK builder and add the d-tag ourselves.
use nostr::{EventBuilder, Kind, Tag};
let mut tags: Vec<Tag> = refs
.iter()
.map(|pk| Tag::parse(&["p", pk]).map_err(|e| CliError::Other(format!("tag error: {e}"))))
.collect::<Result<Vec<_>, _>>()?;
tags.push(Tag::parse(&["d", &dm_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?);
let builder = EventBuilder::new(Kind::Custom(41010), "", tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
// Try to extract relay-assigned channel_id from response message.
// Relay returns: {"event_id":"...","accepted":true,"message":"response:{\"channel_id\":\"...\",\"created\":true}"}
let relay_dm_id = serde_json::from_str::<serde_json::Value>(&resp)
.ok()
.and_then(|v| v.get("message")?.as_str().map(|s| s.to_string()))
.and_then(|msg| {
let json_part = msg.strip_prefix("response:")?;
serde_json::from_str::<serde_json::Value>(json_part).ok()
})
.and_then(|v| v.get("channel_id")?.as_str().map(|s| s.to_string()));
let final_dm_id = relay_dm_id.unwrap_or(dm_id);
let mut normalized: serde_json::Value =
serde_json::from_str(&resp).unwrap_or(serde_json::json!({}));
normalized["dm_id"] = serde_json::json!(final_dm_id);
if normalized.get("accepted").is_none() {
normalized["accepted"] = serde_json::json!(true);
}
println!("{normalized}");
Ok(())
}
@@ -42,7 +104,7 @@ pub async fn cmd_add_dm_member(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}

View File

@@ -1,4 +1,6 @@
use crate::client::SproutClient;
use std::cmp::Reverse;
use crate::client::{normalize_events, SproutClient};
use crate::error::CliError;
/// Get activity feed — query events mentioning our pubkey (via p-tag).
@@ -6,7 +8,7 @@ pub async fn cmd_get_feed(
client: &SproutClient,
since: Option<i64>,
limit: Option<u32>,
_types: Option<&str>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
let my_pk = client.keys().public_key().to_hex();
let limit = limit.unwrap_or(20).min(50);
@@ -21,7 +23,28 @@ pub async fn cmd_get_feed(
}
let resp = client.query(&filter).await?;
println!("{resp}");
let mut events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
events.sort_by_key(|e| Reverse(e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)));
let normalized = normalize_events(&events);
let output = match format {
crate::OutputFormat::Compact => {
let evts: Vec<serde_json::Value> =
serde_json::from_str(&normalized).unwrap_or_default();
let compact: Vec<serde_json::Value> = evts
.iter()
.map(|e| {
serde_json::json!({
"id": e.get("id").cloned().unwrap_or_default(),
"content": e.get("content").cloned().unwrap_or_default(),
"created_at": e.get("created_at").cloned().unwrap_or_default(),
})
})
.collect();
serde_json::to_string(&compact).unwrap_or_default()
}
crate::OutputFormat::Json => normalized,
};
println!("{output}");
Ok(())
}
@@ -29,13 +52,13 @@ pub async fn cmd_get_feed(
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::FeedCmd, client: &SproutClient) -> Result<(), CliError> {
pub async fn dispatch(
cmd: crate::FeedCmd,
client: &SproutClient,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
use crate::FeedCmd;
match cmd {
FeedCmd::Get {
since,
limit,
types,
} => cmd_get_feed(client, since, limit, types.as_deref()).await,
FeedCmd::Get { since, limit } => cmd_get_feed(client, since, limit, format).await,
}
}

View File

@@ -2,7 +2,7 @@ use nostr::PublicKey;
use sprout_sdk::{DiffMeta, ThreadRef, VoteDirection};
use uuid::Uuid;
use crate::client::SproutClient;
use crate::client::{normalize_events, normalize_write_response, SproutClient};
use crate::error::CliError;
use crate::validate::{
infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff,
@@ -222,6 +222,27 @@ fn parse_member_pubkeys(event: &serde_json::Value) -> Vec<String> {
// Read commands — POST /query
// ---------------------------------------------------------------------------
fn format_events(normalized: &str, format: &crate::OutputFormat) -> String {
match format {
crate::OutputFormat::Compact => {
let events: Vec<serde_json::Value> =
serde_json::from_str(normalized).unwrap_or_default();
let compact: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"id": e.get("id").cloned().unwrap_or_default(),
"content": e.get("content").cloned().unwrap_or_default(),
"created_at": e.get("created_at").cloned().unwrap_or_default(),
})
})
.collect();
serde_json::to_string(&compact).unwrap_or_default()
}
crate::OutputFormat::Json => normalized.to_string(),
}
}
pub async fn cmd_get_messages(
client: &SproutClient,
channel_id: &str,
@@ -229,12 +250,13 @@ pub async fn cmd_get_messages(
before: Option<i64>,
since: Option<i64>,
kinds: Option<&str>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let limit = limit.unwrap_or(50).min(200);
let mut filter = serde_json::json!({
"kinds": [9, 40002],
"kinds": [9, 40002, 40008, 45001, 45003],
"#h": [channel_id],
"limit": limit
});
@@ -255,7 +277,10 @@ pub async fn cmd_get_messages(
}
let resp = client.query(&filter).await?;
println!("{resp}");
let mut events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
events.sort_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0));
let normalized = normalize_events(&events);
println!("{}", format_events(&normalized, format));
Ok(())
}
@@ -263,22 +288,31 @@ pub async fn cmd_get_thread(
client: &SproutClient,
channel_id: &str,
event_id: &str,
_depth_limit: Option<u32>,
limit: Option<u32>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
validate_hex64(event_id)?;
let limit = limit.unwrap_or(100).min(500);
// Get the root event and all replies referencing it via e-tag
let filter = serde_json::json!({
"kinds": [9, 40002],
// Two filters ORed in a single HTTP call:
// 1. Replies referencing this event via e-tag (no kind restriction)
// 2. The root event itself by ID
let reply_filter = serde_json::json!({
"kinds": [9, 40002, 40003, 40008, 45003],
"#h": [channel_id],
"#e": [event_id],
"limit": limit
});
let resp = client.query(&filter).await?;
println!("{resp}");
let root_filter = serde_json::json!({
"ids": [event_id],
"limit": 1
});
let resp = client.query_multi(&[reply_filter, root_filter]).await?;
let mut events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
events.sort_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0));
let normalized = normalize_events(&events);
println!("{}", format_events(&normalized, format));
Ok(())
}
@@ -286,14 +320,18 @@ pub async fn cmd_search(
client: &SproutClient,
query: &str,
limit: Option<u32>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
let limit = limit.unwrap_or(20).min(100);
let filter = serde_json::json!({
"kinds": [9, 40002, 45001, 45003],
"search": query,
"limit": limit
});
let resp = client.query(&filter).await?;
println!("{resp}");
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let normalized = normalize_events(&events);
println!("{}", format_events(&normalized, format));
Ok(())
}
@@ -304,7 +342,6 @@ pub async fn cmd_search(
pub struct SendMessageParams {
pub channel_id: String,
pub content: String,
#[allow(dead_code)] // reserved for future kind routing
pub kind: Option<u16>,
pub reply_to: Option<String>,
pub broadcast: bool,
@@ -370,20 +407,44 @@ pub async fn cmd_send_message(
merge_mentions(&mut merged, &auto_resolved, MENTION_CAP);
let mention_refs: Vec<&str> = merged.iter().map(|s| s.as_str()).collect();
let builder = sprout_sdk::build_message(
channel_uuid,
&final_content,
thread_ref.as_ref(),
&mention_refs,
p.broadcast,
&media_tags,
)
.map_err(|e| CliError::Other(format!("build_message failed: {e}")))?;
let builder = match p.kind {
Some(45001) => {
sprout_sdk::build_forum_post(channel_uuid, &final_content, &mention_refs, &media_tags)
.map_err(|e| CliError::Other(format!("build_forum_post failed: {e}")))?
}
Some(45003) => {
let tr = thread_ref.as_ref().ok_or_else(|| {
CliError::Usage("--reply-to is required for forum comments (kind 45003)".into())
})?;
sprout_sdk::build_forum_comment(
channel_uuid,
&final_content,
tr,
&mention_refs,
&media_tags,
)
.map_err(|e| CliError::Other(format!("build_forum_comment failed: {e}")))?
}
None | Some(9) => sprout_sdk::build_message(
channel_uuid,
&final_content,
thread_ref.as_ref(),
&mention_refs,
p.broadcast,
&media_tags,
)
.map_err(|e| CliError::Other(format!("build_message failed: {e}")))?,
Some(k) => {
return Err(CliError::Usage(format!(
"--kind {k} is not supported (use 9, 45001, or 45003)"
)))
}
};
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -474,7 +535,7 @@ pub async fn cmd_send_diff_message(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -491,7 +552,7 @@ pub async fn cmd_delete_message(client: &SproutClient, event_id: &str) -> Result
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -514,7 +575,7 @@ pub async fn cmd_edit_message(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -545,7 +606,7 @@ pub async fn cmd_vote_on_post(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -553,7 +614,11 @@ pub async fn cmd_vote_on_post(
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::MessagesCmd, client: &SproutClient) -> Result<(), CliError> {
pub async fn dispatch(
cmd: crate::MessagesCmd,
client: &SproutClient,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
use crate::MessagesCmd;
match cmd {
MessagesCmd::Send {
@@ -620,14 +685,24 @@ pub async fn dispatch(cmd: crate::MessagesCmd, client: &SproutClient) -> Result<
before,
since,
kinds,
} => cmd_get_messages(client, &channel, limit, before, since, kinds.as_deref()).await,
} => {
cmd_get_messages(
client,
&channel,
limit,
before,
since,
kinds.as_deref(),
format,
)
.await
}
MessagesCmd::Thread {
channel,
event,
depth_limit,
limit,
} => cmd_get_thread(client, &channel, &event, depth_limit, limit).await,
MessagesCmd::Search { query, limit } => cmd_search(client, &query, limit).await,
} => cmd_get_thread(client, &channel, &event, limit, format).await,
MessagesCmd::Search { query, limit } => cmd_search(client, &query, limit, format).await,
MessagesCmd::Vote { event, direction } => {
cmd_vote_on_post(client, &event, &direction).await
}

View File

@@ -1,6 +1,8 @@
use std::collections::HashMap;
use nostr::EventId;
use crate::client::SproutClient;
use crate::client::{normalize_write_response, SproutClient};
use crate::error::CliError;
use crate::validate::validate_hex64;
@@ -19,7 +21,7 @@ pub async fn cmd_add_reaction(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -65,19 +67,54 @@ pub async fn cmd_remove_reaction(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_get_reactions(client: &SproutClient, event_id: &str) -> Result<(), CliError> {
validate_hex64(event_id)?;
// Query kind:7 reactions referencing this event
let filter = serde_json::json!({
"kinds": [7],
"#e": [event_id]
});
let resp = client.query(&filter).await?;
println!("{resp}");
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let mut groups: HashMap<String, Vec<String>> = HashMap::new();
for e in &events {
let emoji = e
.get("content")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("+")
.to_string();
let pubkey = e
.get("pubkey")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
groups.entry(emoji).or_default().push(pubkey);
}
let mut reactions: Vec<serde_json::Value> = groups
.into_iter()
.map(|(emoji, pubkeys)| {
serde_json::json!({
"emoji": emoji,
"count": pubkeys.len(),
"pubkeys": pubkeys,
})
})
.collect();
reactions.sort_by(|a, b| {
a.get("emoji")
.and_then(|v| v.as_str())
.unwrap_or("")
.cmp(b.get("emoji").and_then(|v| v.as_str()).unwrap_or(""))
});
let output = serde_json::json!({ "reactions": reactions });
println!("{}", serde_json::to_string(&output).unwrap_or_default());
Ok(())
}

View File

@@ -5,7 +5,7 @@ use sprout_sdk::kind::{
KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST,
};
use crate::client::SproutClient;
use crate::client::{normalize_write_response, SproutClient};
use crate::error::CliError;
use crate::validate::{parse_event_id, validate_hex64};
@@ -36,7 +36,7 @@ pub async fn cmd_publish_note(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -64,7 +64,7 @@ pub async fn cmd_set_contact_list(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}

View File

@@ -1,4 +1,4 @@
use crate::client::SproutClient;
use crate::client::{normalize_write_response, SproutClient};
use crate::error::CliError;
use crate::validate::validate_hex64;
@@ -13,6 +13,7 @@ pub async fn cmd_get_users(
client: &SproutClient,
pubkeys: &[String],
name: Option<&str>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
if let Some(query) = name {
if !pubkeys.is_empty() {
@@ -20,7 +21,7 @@ pub async fn cmd_get_users(
"--name and --pubkey are mutually exclusive".into(),
));
}
return search_by_name(client, query).await;
return search_by_name(client, query, format).await;
}
for pk in pubkeys {
@@ -43,13 +44,45 @@ pub async fn cmd_get_users(
"limit": authors.len()
});
let resp = client.query(&filter).await?;
println!("{resp}");
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let profiles: Vec<serde_json::Value> = events
.iter()
.filter_map(|e| {
let content_str = e.get("content")?.as_str()?;
let mut profile: serde_json::Value = serde_json::from_str(content_str).ok()?;
if let Some(obj) = profile.as_object_mut() {
obj.insert(
"pubkey".to_string(),
serde_json::json!(e.get("pubkey").and_then(|v| v.as_str()).unwrap_or("")),
);
}
Some(profile)
})
.collect();
let output = match format {
crate::OutputFormat::Compact => {
let compact: Vec<serde_json::Value> = profiles
.iter()
.map(|p| serde_json::json!({
"pubkey": p.get("pubkey").cloned().unwrap_or_default(),
"display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(),
}))
.collect();
serde_json::to_string(&compact).unwrap_or_default()
}
crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(),
};
println!("{output}");
Ok(())
}
/// Search for users by display name via NIP-50 full-text search on kind:0 profiles.
/// Returns [] if the relay does not implement NIP-50 search.
async fn search_by_name(client: &SproutClient, query: &str) -> Result<(), CliError> {
async fn search_by_name(
client: &SproutClient,
query: &str,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
if query.trim().is_empty() {
return Err(CliError::Usage("--name cannot be empty".into()));
}
@@ -72,26 +105,44 @@ async fn search_by_name(client: &SproutClient, query: &str) -> Result<(), CliErr
};
let lower_query = query.to_ascii_lowercase();
let matches: Vec<&serde_json::Value> = arr
let profiles: Vec<serde_json::Value> = arr
.iter()
.filter(|event| {
let Some(content_str) = event.get("content").and_then(|v| v.as_str()) else {
return false;
};
let Ok(content) = serde_json::from_str::<serde_json::Value>(content_str) else {
return false;
};
.filter_map(|event| {
let content_str = event.get("content").and_then(|v| v.as_str())?;
let content: serde_json::Value = serde_json::from_str(content_str).ok()?;
let display_name = content
.get("display_name")
.and_then(|v| v.as_str())
.unwrap_or("");
let name = content.get("name").and_then(|v| v.as_str()).unwrap_or("");
display_name.to_ascii_lowercase().contains(&lower_query)
|| name.to_ascii_lowercase().contains(&lower_query)
if !display_name.to_ascii_lowercase().contains(&lower_query)
&& !name.to_ascii_lowercase().contains(&lower_query)
{
return None;
}
let mut profile = content;
if let Some(obj) = profile.as_object_mut() {
obj.insert(
"pubkey".to_string(),
serde_json::json!(event.get("pubkey").and_then(|v| v.as_str()).unwrap_or("")),
);
}
Some(profile)
})
.collect();
let output = serde_json::to_string(&matches).expect("serializing parsed JSON values");
let output = match format {
crate::OutputFormat::Compact => {
let compact: Vec<serde_json::Value> = profiles
.iter()
.map(|p| serde_json::json!({
"pubkey": p.get("pubkey").cloned().unwrap_or_default(),
"display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(),
}))
.collect();
serde_json::to_string(&compact).unwrap_or_default()
}
crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(),
};
println!("{output}");
Ok(())
}
@@ -158,7 +209,7 @@ pub async fn cmd_set_profile(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -209,7 +260,19 @@ pub async fn cmd_get_presence(client: &SproutClient, pubkeys_csv: &str) -> Resul
"limit": pubkeys.len()
});
let resp = client.query(&filter).await?;
println!("{resp}");
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let presence: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""),
"status": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
"updated_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
})
})
.collect();
let output = serde_json::to_string(&presence).unwrap_or_default();
println!("{output}");
Ok(())
}
@@ -224,7 +287,7 @@ pub async fn cmd_set_presence(client: &SproutClient, status: &str) -> Result<(),
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -232,10 +295,16 @@ pub async fn cmd_set_presence(client: &SproutClient, status: &str) -> Result<(),
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::UsersCmd, client: &SproutClient) -> Result<(), CliError> {
pub async fn dispatch(
cmd: crate::UsersCmd,
client: &SproutClient,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
use crate::UsersCmd;
match cmd {
UsersCmd::Get { pubkeys, name } => cmd_get_users(client, &pubkeys, name.as_deref()).await,
UsersCmd::Get { pubkeys, name } => {
cmd_get_users(client, &pubkeys, name.as_deref(), format).await
}
UsersCmd::SetProfile {
name,
avatar,

View File

@@ -1,6 +1,6 @@
use sha2::{Digest, Sha256};
use crate::client::SproutClient;
use crate::client::{extract_d_tag, normalize_write_response, print_create_response, SproutClient};
use crate::error::CliError;
use crate::validate::{parse_uuid, read_or_stdin, sdk_err, validate_uuid};
@@ -18,7 +18,20 @@ pub async fn cmd_list_workflows(client: &SproutClient, channel_id: &str) -> Resu
"#h": [channel_id]
});
let resp = client.query(&filter).await?;
println!("{resp}");
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let workflows: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"workflow_id": extract_d_tag(e),
"content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
"pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""),
})
})
.collect();
let output = serde_json::to_string(&workflows).unwrap_or_default();
println!("{output}");
Ok(())
}
@@ -30,11 +43,27 @@ pub async fn cmd_get_workflow(client: &SproutClient, workflow_id: &str) -> Resul
"#d": [workflow_id]
});
let resp = client.query(&filter).await?;
println!("{resp}");
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
if let Some(e) = events.first() {
let normalized = serde_json::json!({
"workflow_id": extract_d_tag(e),
"content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
"pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""),
});
println!("{normalized}");
} else {
println!("null");
}
Ok(())
}
/// Get workflow run history — query kind:46020 trigger events for this workflow.
/// Get workflow run history — query kinds [46001, 46002, 46003].
///
/// NOTE: The relay does not currently emit workflow execution events (46001-46003).
/// Run history is stored in the workflow_runs DB table, not as Nostr events.
/// This command will return an empty array until the relay adds event emission
/// or a dedicated REST endpoint for run history.
pub async fn cmd_get_workflow_runs(
client: &SproutClient,
workflow_id: &str,
@@ -43,12 +72,26 @@ pub async fn cmd_get_workflow_runs(
validate_uuid(workflow_id)?;
let limit = limit.unwrap_or(20).min(100);
let filter = serde_json::json!({
"kinds": [46020],
"kinds": [46001, 46002, 46003],
"#d": [workflow_id],
"limit": limit
});
let resp = client.query(&filter).await?;
println!("{resp}");
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let normalized: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"event_id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""),
"kind": e.get("kind").and_then(|v| v.as_u64()).unwrap_or(0),
"content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
"tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])),
})
})
.collect();
let output = serde_json::to_string(&normalized).unwrap_or_default();
println!("{output}");
Ok(())
}
@@ -71,7 +114,7 @@ pub async fn cmd_create_workflow(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
print_create_response(&resp, "workflow_id", &workflow_id.to_string());
Ok(())
}
@@ -91,7 +134,7 @@ pub async fn cmd_update_workflow(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -105,7 +148,7 @@ pub async fn cmd_delete_workflow(client: &SproutClient, workflow_id: &str) -> Re
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -120,7 +163,7 @@ pub async fn cmd_trigger_workflow(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}
@@ -142,7 +185,7 @@ pub async fn cmd_approve_step(
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
println!("{}", normalize_write_response(&resp));
Ok(())
}

View File

@@ -83,6 +83,10 @@ struct Cli {
#[arg(long, env = "SPROUT_AUTH_TAG")]
auth_tag: Option<String>,
/// Output format: 'json' (default, full fields) or 'compact' (reduced fields).
#[arg(long, value_enum, default_value = "json")]
format: OutputFormat,
#[command(subcommand)]
command: Cmd,
}
@@ -145,6 +149,18 @@ impl std::fmt::Display for PresenceStatus {
}
}
/// Output format for read commands.
#[derive(Clone, clap::ValueEnum, Default)]
pub enum OutputFormat {
/// Full normalized JSON (default)
#[default]
#[value(name = "json")]
Json,
/// Reduced fields for agent scanning
#[value(name = "compact")]
Compact,
}
// ---------------------------------------------------------------------------
// Subcommand groups
// ---------------------------------------------------------------------------
@@ -311,9 +327,6 @@ pub enum MessagesCmd {
/// Root message event ID (64-char hex)
#[arg(long)]
event: String,
/// Maximum reply depth to traverse
#[arg(long)]
depth_limit: Option<u32>,
/// Maximum number of results to return
#[arg(long)]
limit: Option<u32>,
@@ -355,6 +368,9 @@ pub enum ChannelsCmd {
/// Only show channels where the current identity is a member
#[arg(long, default_value_t = false)]
member: bool,
/// Maximum number of channels to return [default: 500]
#[arg(long)]
limit: Option<u32>,
},
/// Get details for a single channel
Get {
@@ -693,9 +709,6 @@ pub enum FeedCmd {
/// Maximum number of results to return
#[arg(long)]
limit: Option<u32>,
/// Comma-separated feed entry types to filter
#[arg(long)]
types: Option<String>,
},
}
@@ -1056,14 +1069,14 @@ async fn run(cli: Cli) -> Result<(), CliError> {
let client = SproutClient::new(relay_url, keys, auth_tag, auth_tag_json)?;
match cli.command {
Cmd::Messages(sub) => commands::messages::dispatch(sub, &client).await,
Cmd::Channels(sub) => commands::channels::dispatch(sub, &client).await,
Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await,
Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await,
Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await,
Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await,
Cmd::Dms(sub) => commands::dms::dispatch(sub, &client).await,
Cmd::Users(sub) => commands::users::dispatch(sub, &client).await,
Cmd::Users(sub) => commands::users::dispatch(sub, &client, &cli.format).await,
Cmd::Workflows(sub) => commands::workflows::dispatch(sub, &client).await,
Cmd::Feed(sub) => commands::feed::dispatch(sub, &client).await,
Cmd::Feed(sub) => commands::feed::dispatch(sub, &client, &cli.format).await,
Cmd::Social(sub) => commands::social::dispatch(sub, &client).await,
Cmd::Notes(sub) => commands::notes::dispatch(sub, &client).await,
Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await,