mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
1044 lines
30 KiB
Rust
1044 lines
30 KiB
Rust
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",
|
|
long_about = "\
|
|
Sprout CLI — interact with a Sprout relay
|
|
|
|
Configuration (flags override env vars):
|
|
SPROUT_RELAY_URL Relay base URL [default: http://localhost:3000]
|
|
SPROUT_PRIVATE_KEY Nostr private key (hex or nsec) [required]
|
|
SPROUT_AUTH_TAG NIP-OA auth tag JSON [optional]
|
|
|
|
The 'pack' subcommand runs locally and does not require a relay connection.
|
|
|
|
Exit codes: 0=ok 1=bad input 2=relay/network error 3=auth error 4=other
|
|
Errors are JSON on stderr: {\"error\": \"<category>\", \"message\": \"<detail>\"}"
|
|
)]
|
|
struct Cli {
|
|
/// Relay URL (http:// or https://). Overrides SPROUT_RELAY_URL env var.
|
|
#[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,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Value enums for typed --type / --visibility / --status flags
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone, clap::ValueEnum)]
|
|
pub enum ChannelType {
|
|
#[value(name = "stream")]
|
|
Stream,
|
|
#[value(name = "forum")]
|
|
Forum,
|
|
}
|
|
|
|
impl std::fmt::Display for ChannelType {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Stream => write!(f, "stream"),
|
|
Self::Forum => write!(f, "forum"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, clap::ValueEnum)]
|
|
pub enum ChannelVisibility {
|
|
#[value(name = "open")]
|
|
Open,
|
|
#[value(name = "private")]
|
|
Private,
|
|
}
|
|
|
|
impl std::fmt::Display for ChannelVisibility {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Open => write!(f, "open"),
|
|
Self::Private => write!(f, "private"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, clap::ValueEnum)]
|
|
pub enum PresenceStatus {
|
|
#[value(name = "online")]
|
|
Online,
|
|
#[value(name = "away")]
|
|
Away,
|
|
#[value(name = "offline")]
|
|
Offline,
|
|
}
|
|
|
|
impl std::fmt::Display for PresenceStatus {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Online => write!(f, "online"),
|
|
Self::Away => write!(f, "away"),
|
|
Self::Offline => write!(f, "offline"),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand groups
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Subcommand)]
|
|
enum Cmd {
|
|
/// Send, read, search, and manage messages
|
|
#[command(subcommand)]
|
|
Messages(MessagesCmd),
|
|
/// Create, configure, and manage channels
|
|
#[command(subcommand)]
|
|
Channels(ChannelsCmd),
|
|
/// Get and set channel canvas documents
|
|
#[command(subcommand)]
|
|
Canvas(CanvasCmd),
|
|
/// Add, remove, and list emoji reactions
|
|
#[command(subcommand)]
|
|
Reactions(ReactionsCmd),
|
|
/// List, open, and manage direct messages
|
|
#[command(subcommand)]
|
|
Dms(DmsCmd),
|
|
/// Look up users and manage profiles and presence
|
|
#[command(subcommand)]
|
|
Users(UsersCmd),
|
|
/// Create, trigger, and manage workflows
|
|
#[command(subcommand)]
|
|
Workflows(WorkflowsCmd),
|
|
/// Read the activity feed
|
|
#[command(subcommand)]
|
|
Feed(FeedCmd),
|
|
/// Publish notes and manage the social graph (NIP-01/02)
|
|
#[command(subcommand)]
|
|
Social(SocialCmd),
|
|
/// Announce and discover git repositories (NIP-34)
|
|
#[command(subcommand)]
|
|
Repos(ReposCmd),
|
|
/// Upload files to the relay's Blossom store
|
|
#[command(subcommand)]
|
|
Upload(UploadCmd),
|
|
/// Persona pack operations (local, no relay connection needed)
|
|
#[command(subcommand)]
|
|
Pack(PackCmd),
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Messages subcommands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum MessagesCmd {
|
|
/// Send a message to a channel
|
|
#[command(
|
|
after_help = "Examples:\n sprout messages send --channel <UUID> --content \"hello\"\n sprout messages send --channel <UUID> --content \"@alice check this\""
|
|
)]
|
|
Send {
|
|
/// Channel UUID (from 'sprout channels list')
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// Message text — supports @mentions and markdown
|
|
#[arg(long)]
|
|
content: String,
|
|
/// Nostr event kind (default: channel default)
|
|
#[arg(long)]
|
|
kind: Option<u16>,
|
|
/// Event ID to reply to (creates a thread)
|
|
#[arg(long)]
|
|
reply_to: Option<String>,
|
|
/// Also publish to the Nostr network
|
|
#[arg(long, default_value_t = false)]
|
|
broadcast: bool,
|
|
/// Explicit mention pubkeys (64-char hex)
|
|
#[arg(long = "mention")]
|
|
mentions: Vec<String>,
|
|
/// Attach file(s) — uploads and includes as imeta tags
|
|
#[arg(long = "file")]
|
|
files: Vec<String>,
|
|
},
|
|
/// Send a code diff / patch to a channel
|
|
SendDiff {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// Diff/patch content (use '-' to read from stdin)
|
|
#[arg(long)]
|
|
diff: String,
|
|
/// Repository URL (e.g. https://github.com/org/repo)
|
|
#[arg(long)]
|
|
repo: String,
|
|
/// Commit SHA
|
|
#[arg(long)]
|
|
commit: String,
|
|
/// Single file path within the repo
|
|
#[arg(long)]
|
|
file: Option<String>,
|
|
/// Parent commit SHA for three-way diff context
|
|
#[arg(long)]
|
|
parent_commit: Option<String>,
|
|
/// Source branch name
|
|
#[arg(long)]
|
|
source_branch: Option<String>,
|
|
/// Target branch name
|
|
#[arg(long)]
|
|
target_branch: Option<String>,
|
|
/// Pull request number
|
|
#[arg(long)]
|
|
pr: Option<u32>,
|
|
/// Language hint (auto-detected from file extension if omitted)
|
|
#[arg(long)]
|
|
lang: Option<String>,
|
|
/// Human-readable description of the change
|
|
#[arg(long)]
|
|
description: Option<String>,
|
|
/// Event ID to reply to (creates a thread)
|
|
#[arg(long)]
|
|
reply_to: Option<String>,
|
|
},
|
|
/// Edit a previously sent message
|
|
Edit {
|
|
/// Event ID of the message to edit (64-char hex)
|
|
#[arg(long)]
|
|
event: String,
|
|
/// New message content
|
|
#[arg(long)]
|
|
content: String,
|
|
},
|
|
/// Delete a message by event ID
|
|
Delete {
|
|
/// Event ID to delete (64-char hex)
|
|
#[arg(long)]
|
|
event: String,
|
|
},
|
|
/// Retrieve messages from a channel
|
|
#[command(
|
|
after_help = "Examples:\n sprout messages get --channel <UUID>\n sprout messages get --channel <UUID> --limit 50 --kinds 1,1984"
|
|
)]
|
|
Get {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// Maximum number of results to return
|
|
#[arg(long)]
|
|
limit: Option<u32>,
|
|
/// Unix timestamp — return messages before this time
|
|
#[arg(long)]
|
|
before: Option<i64>,
|
|
/// Unix timestamp — return messages after this time
|
|
#[arg(long)]
|
|
since: Option<i64>,
|
|
/// Comma-separated event kinds to filter (e.g. 1,1984)
|
|
#[arg(long)]
|
|
kinds: Option<String>,
|
|
},
|
|
/// Get a message thread (replies to a root message)
|
|
Thread {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// 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>,
|
|
},
|
|
/// Full-text search across messages
|
|
Search {
|
|
/// Search query string
|
|
#[arg(long)]
|
|
query: String,
|
|
/// Maximum number of results to return
|
|
#[arg(long)]
|
|
limit: Option<u32>,
|
|
},
|
|
/// Upvote or downvote a forum post
|
|
Vote {
|
|
/// Event ID of the post to vote on (64-char hex)
|
|
#[arg(long)]
|
|
event: String,
|
|
/// Vote direction: "up" or "down"
|
|
#[arg(long)]
|
|
direction: String,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Channels subcommands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum ChannelsCmd {
|
|
/// List channels visible to the current identity
|
|
#[command(
|
|
after_help = "Examples:\n sprout channels list\n sprout channels list --visibility open"
|
|
)]
|
|
List {
|
|
/// Filter by visibility
|
|
#[arg(long, value_enum)]
|
|
visibility: Option<ChannelVisibility>,
|
|
/// Only show channels where the current identity is a member
|
|
#[arg(long, default_value_t = false)]
|
|
member: bool,
|
|
},
|
|
/// Get details for a single channel
|
|
Get {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
},
|
|
/// Create a new channel
|
|
#[command(
|
|
after_help = "Examples:\n sprout channels create --name general --type stream --visibility open\n sprout channels create --name design --type forum --visibility open --description \"Design discussions\""
|
|
)]
|
|
Create {
|
|
/// Channel name
|
|
#[arg(long)]
|
|
name: String,
|
|
/// Channel type
|
|
#[arg(long = "type", value_enum)]
|
|
channel_type: ChannelType,
|
|
/// Channel visibility
|
|
#[arg(long, value_enum)]
|
|
visibility: ChannelVisibility,
|
|
/// Channel description
|
|
#[arg(long)]
|
|
description: Option<String>,
|
|
},
|
|
/// Update channel name or description
|
|
Update {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// New channel name
|
|
#[arg(long)]
|
|
name: Option<String>,
|
|
/// New channel description
|
|
#[arg(long)]
|
|
description: Option<String>,
|
|
},
|
|
/// Set the channel topic
|
|
Topic {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// New topic text
|
|
#[arg(long)]
|
|
topic: String,
|
|
},
|
|
/// Set the channel purpose
|
|
Purpose {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// New purpose text
|
|
#[arg(long)]
|
|
purpose: String,
|
|
},
|
|
/// Join a channel
|
|
Join {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
},
|
|
/// Leave a channel
|
|
Leave {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
},
|
|
/// Archive a channel
|
|
Archive {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
},
|
|
/// Unarchive a channel
|
|
Unarchive {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
},
|
|
/// Delete a channel permanently
|
|
Delete {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
},
|
|
/// List members of a channel
|
|
Members {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
},
|
|
/// Add a member to a channel
|
|
#[command(name = "add-member")]
|
|
AddMember {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// Member pubkey (64-char hex)
|
|
#[arg(long)]
|
|
pubkey: String,
|
|
/// Member role (owner, admin, member, guest, bot)
|
|
#[arg(long)]
|
|
role: Option<String>,
|
|
},
|
|
/// Remove a member from a channel
|
|
#[command(name = "remove-member")]
|
|
RemoveMember {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// Member pubkey (64-char hex)
|
|
#[arg(long)]
|
|
pubkey: String,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Canvas subcommands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum CanvasCmd {
|
|
/// Get the canvas document for a channel
|
|
Get {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
},
|
|
/// Set (replace) the canvas document for a channel
|
|
Set {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// Canvas content (markdown; use '-' to read from stdin)
|
|
#[arg(long)]
|
|
content: String,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reactions subcommands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum ReactionsCmd {
|
|
/// Add an emoji reaction to a message
|
|
Add {
|
|
/// Event ID (64-char hex)
|
|
#[arg(long)]
|
|
event: String,
|
|
/// Emoji character (e.g. '👍')
|
|
#[arg(long)]
|
|
emoji: String,
|
|
},
|
|
/// Remove an emoji reaction from a message
|
|
Remove {
|
|
/// Event ID (64-char hex)
|
|
#[arg(long)]
|
|
event: String,
|
|
/// Emoji character to remove
|
|
#[arg(long)]
|
|
emoji: String,
|
|
},
|
|
/// List reactions on a message
|
|
Get {
|
|
/// Event ID (64-char hex)
|
|
#[arg(long)]
|
|
event: String,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DMs subcommands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum DmsCmd {
|
|
/// List direct message conversations
|
|
List {
|
|
/// Maximum number of results to return
|
|
#[arg(long)]
|
|
limit: Option<u32>,
|
|
},
|
|
/// Open a new direct message with one or more users
|
|
Open {
|
|
/// User pubkey(s) to DM (64-char hex, 1-8)
|
|
#[arg(long = "pubkey")]
|
|
pubkeys: Vec<String>,
|
|
},
|
|
/// Add a member to an existing DM conversation
|
|
AddMember {
|
|
/// DM conversation UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// User pubkey to add (64-char hex)
|
|
#[arg(long)]
|
|
pubkey: String,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Users subcommands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum UsersCmd {
|
|
/// Look up user profiles by pubkey or name
|
|
Get {
|
|
/// User pubkey(s) to look up (64-char hex). Omit for your own profile
|
|
#[arg(long = "pubkey")]
|
|
pubkeys: Vec<String>,
|
|
/// Search by display name (case-insensitive substring match)
|
|
#[arg(long = "name")]
|
|
name: Option<String>,
|
|
},
|
|
/// Update the current identity's profile
|
|
#[command(name = "set-profile")]
|
|
SetProfile {
|
|
/// Display name
|
|
#[arg(long)]
|
|
name: Option<String>,
|
|
/// Avatar URL
|
|
#[arg(long)]
|
|
avatar: Option<String>,
|
|
/// Bio / about text
|
|
#[arg(long)]
|
|
about: Option<String>,
|
|
/// NIP-05 identifier (e.g. user@example.com)
|
|
#[arg(long)]
|
|
nip05: Option<String>,
|
|
},
|
|
/// Get presence status for users
|
|
Presence {
|
|
/// Comma-separated pubkeys (64-char hex)
|
|
#[arg(long)]
|
|
pubkeys: String,
|
|
},
|
|
/// Set your presence status (online/away/offline)
|
|
#[command(name = "set-presence")]
|
|
SetPresence {
|
|
/// Presence status
|
|
#[arg(long, value_enum)]
|
|
status: PresenceStatus,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Workflows subcommands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum WorkflowsCmd {
|
|
/// List workflows in a channel
|
|
List {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
},
|
|
/// Get details for a single workflow
|
|
Get {
|
|
/// Workflow UUID
|
|
#[arg(long)]
|
|
workflow: String,
|
|
},
|
|
/// Create a workflow from a YAML definition
|
|
Create {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// Workflow YAML definition
|
|
#[arg(long)]
|
|
yaml: String,
|
|
},
|
|
/// Update a workflow's YAML definition
|
|
Update {
|
|
/// Channel UUID the workflow belongs to
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// Workflow UUID
|
|
#[arg(long)]
|
|
workflow: String,
|
|
/// Updated workflow YAML definition
|
|
#[arg(long)]
|
|
yaml: String,
|
|
},
|
|
/// Delete a workflow
|
|
Delete {
|
|
/// Workflow UUID
|
|
#[arg(long)]
|
|
workflow: String,
|
|
},
|
|
/// Trigger a workflow run
|
|
#[command(after_help = "Examples:\n sprout workflows trigger --workflow <UUID>")]
|
|
Trigger {
|
|
/// Workflow UUID
|
|
#[arg(long)]
|
|
workflow: String,
|
|
},
|
|
/// List runs for a workflow
|
|
Runs {
|
|
/// Workflow UUID
|
|
#[arg(long)]
|
|
workflow: String,
|
|
/// Maximum number of results to return
|
|
#[arg(long)]
|
|
limit: Option<u32>,
|
|
},
|
|
/// Approve or deny a workflow step
|
|
#[command(
|
|
after_help = "Examples:\n sprout workflows approve --token <UUID>\n sprout workflows approve --token <UUID> --approved false --note \"needs revision\""
|
|
)]
|
|
Approve {
|
|
/// The approval token UUID (from the approval request)
|
|
#[arg(long)]
|
|
token: String,
|
|
/// Approve (true) or deny (false) the step
|
|
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
|
|
approved: bool,
|
|
/// Optional note to include with the approval/denial
|
|
#[arg(long)]
|
|
note: Option<String>,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Feed subcommands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum FeedCmd {
|
|
/// Get recent activity feed entries
|
|
Get {
|
|
/// Unix timestamp — return entries after this time
|
|
#[arg(long)]
|
|
since: Option<i64>,
|
|
/// Maximum number of results to return
|
|
#[arg(long)]
|
|
limit: Option<u32>,
|
|
/// Comma-separated feed entry types to filter
|
|
#[arg(long)]
|
|
types: Option<String>,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Social subcommands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum SocialCmd {
|
|
/// Publish a text note (NIP-01 kind:1)
|
|
#[command(name = "publish")]
|
|
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 your contact list (NIP-02 kind:3)
|
|
#[command(name = "set-contacts")]
|
|
SetContactList {
|
|
/// JSON array of contacts: [{"pubkey":"hex","relay_url":"...","petname":"..."}]
|
|
#[arg(long)]
|
|
contacts: String,
|
|
},
|
|
/// Get a single event by ID
|
|
#[command(name = "event")]
|
|
GetEvent {
|
|
/// 64-char hex event ID.
|
|
#[arg(long)]
|
|
event: String,
|
|
},
|
|
/// Get recent notes published by a user
|
|
#[command(name = "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 list
|
|
#[command(name = "contacts")]
|
|
GetContactList {
|
|
/// 64-char hex pubkey.
|
|
#[arg(long)]
|
|
pubkey: String,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Repos subcommands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum ReposCmd {
|
|
/// Announce a git repository (NIP-34)
|
|
Create {
|
|
/// Repository identifier: [a-zA-Z0-9._-]{1,64}
|
|
#[arg(long)]
|
|
id: String,
|
|
/// Human-readable display name
|
|
#[arg(long)]
|
|
name: Option<String>,
|
|
/// Repository description
|
|
#[arg(long)]
|
|
description: Option<String>,
|
|
/// Clone URL(s) — can be specified multiple times
|
|
#[arg(long = "clone")]
|
|
clone_urls: Vec<String>,
|
|
/// Web browsing URL
|
|
#[arg(long)]
|
|
web: Option<String>,
|
|
/// Preferred Nostr relay(s) for repo discovery — can be specified multiple times
|
|
#[arg(long = "nostr-relay")]
|
|
relays: Vec<String>,
|
|
},
|
|
/// Get a repository announcement
|
|
Get {
|
|
/// Repository identifier (d-tag)
|
|
#[arg(long)]
|
|
id: String,
|
|
/// Owner pubkey (64-char hex). Omit to match any owner.
|
|
#[arg(long)]
|
|
owner: Option<String>,
|
|
},
|
|
/// List repository announcements
|
|
List {
|
|
/// Owner pubkey (64-char hex). Omit for your repos.
|
|
#[arg(long)]
|
|
owner: Option<String>,
|
|
/// Maximum number of results
|
|
#[arg(long)]
|
|
limit: Option<u32>,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Upload subcommands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum UploadCmd {
|
|
/// Upload a file to the relay's Blossom store
|
|
File {
|
|
/// Path to the file to upload
|
|
#[arg(long)]
|
|
file: String,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pack subcommands (local, no relay connection needed)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Subcommands for `sprout pack`.
|
|
#[derive(Subcommand)]
|
|
pub 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,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Command dispatch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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 {
|
|
Cmd::Messages(sub) => commands::messages::dispatch(sub, &client).await,
|
|
Cmd::Channels(sub) => commands::channels::dispatch(sub, &client).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::Workflows(sub) => commands::workflows::dispatch(sub, &client).await,
|
|
Cmd::Feed(sub) => commands::feed::dispatch(sub, &client).await,
|
|
Cmd::Social(sub) => commands::social::dispatch(sub, &client).await,
|
|
Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await,
|
|
Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await,
|
|
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();
|
|
}
|
|
|
|
#[test]
|
|
fn command_inventory_is_stable() {
|
|
let expected_groups: Vec<&str> = vec![
|
|
"canvas",
|
|
"channels",
|
|
"dms",
|
|
"feed",
|
|
"messages",
|
|
"pack",
|
|
"reactions",
|
|
"repos",
|
|
"social",
|
|
"upload",
|
|
"users",
|
|
"workflows",
|
|
];
|
|
|
|
let cmd = Cli::command();
|
|
let mut actual: Vec<String> = cmd
|
|
.get_subcommands()
|
|
.map(|s| s.get_name().to_string())
|
|
.filter(|n| n != "help")
|
|
.collect();
|
|
actual.sort();
|
|
|
|
assert_eq!(
|
|
actual.len(),
|
|
expected_groups.len(),
|
|
"Expected {} groups, got {}. Actual: {:?}",
|
|
expected_groups.len(),
|
|
actual.len(),
|
|
actual
|
|
);
|
|
assert_eq!(
|
|
actual, expected_groups,
|
|
"Command group inventory drift detected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn subcommand_names_are_stable() {
|
|
fn names(cmd: &clap::Command, group: &str) -> Vec<String> {
|
|
let group_cmd = cmd
|
|
.get_subcommands()
|
|
.find(|s| s.get_name() == group)
|
|
.unwrap_or_else(|| panic!("group '{}' not found", group));
|
|
let mut names: Vec<String> = group_cmd
|
|
.get_subcommands()
|
|
.map(|s| s.get_name().to_string())
|
|
.filter(|n| n != "help")
|
|
.collect();
|
|
names.sort();
|
|
names
|
|
}
|
|
|
|
let cmd = Cli::command();
|
|
assert_eq!(
|
|
names(&cmd, "messages"),
|
|
vec![
|
|
"delete",
|
|
"edit",
|
|
"get",
|
|
"search",
|
|
"send",
|
|
"send-diff",
|
|
"thread",
|
|
"vote"
|
|
]
|
|
);
|
|
assert_eq!(
|
|
names(&cmd, "channels"),
|
|
vec![
|
|
"add-member",
|
|
"archive",
|
|
"create",
|
|
"delete",
|
|
"get",
|
|
"join",
|
|
"leave",
|
|
"list",
|
|
"members",
|
|
"purpose",
|
|
"remove-member",
|
|
"topic",
|
|
"unarchive",
|
|
"update"
|
|
]
|
|
);
|
|
assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]);
|
|
assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]);
|
|
assert_eq!(names(&cmd, "dms"), vec!["add-member", "list", "open"]);
|
|
assert_eq!(
|
|
names(&cmd, "users"),
|
|
vec!["get", "presence", "set-presence", "set-profile"]
|
|
);
|
|
assert_eq!(
|
|
names(&cmd, "workflows"),
|
|
vec!["approve", "create", "delete", "get", "list", "runs", "trigger", "update"]
|
|
);
|
|
assert_eq!(names(&cmd, "feed"), vec!["get"]);
|
|
assert_eq!(
|
|
names(&cmd, "social"),
|
|
vec!["contacts", "event", "notes", "publish", "set-contacts"]
|
|
);
|
|
assert_eq!(names(&cmd, "repos"), vec!["create", "get", "list"]);
|
|
assert_eq!(names(&cmd, "upload"), vec!["file"]);
|
|
assert_eq!(names(&cmd, "pack"), vec!["inspect", "validate"]);
|
|
}
|
|
|
|
#[test]
|
|
fn subcommand_counts_are_stable() {
|
|
let expected: Vec<(&str, usize)> = vec![
|
|
("canvas", 2),
|
|
("channels", 14),
|
|
("dms", 3),
|
|
("feed", 1),
|
|
("messages", 8),
|
|
("pack", 2),
|
|
("reactions", 3),
|
|
("repos", 3),
|
|
("social", 5),
|
|
("upload", 1),
|
|
("users", 4),
|
|
("workflows", 8),
|
|
];
|
|
|
|
let cmd = Cli::command();
|
|
for (group_name, expected_count) in &expected {
|
|
let group = cmd
|
|
.get_subcommands()
|
|
.find(|s| s.get_name() == *group_name)
|
|
.unwrap_or_else(|| panic!("group '{}' not found", group_name));
|
|
let actual_count = group
|
|
.get_subcommands()
|
|
.filter(|s| s.get_name() != "help")
|
|
.count();
|
|
assert_eq!(
|
|
actual_count, *expected_count,
|
|
"Group '{}': expected {} subcommands, got {}",
|
|
group_name, expected_count, actual_count
|
|
);
|
|
}
|
|
}
|
|
}
|