mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Why `buzz channels update` could already change name, description, and TTL, but the SDK/relay/DB path for channel visibility was unreachable from the CLI. ## What - Add `--visibility open|private` to `buzz channels update` - Pass the visibility value through to `build_update_channel` - Add guard tests proving empty updates still fail and visibility-only updates are accepted ## Risk Assessment Low — this is limited to the buzz-cli update command and uses existing SDK validation plus existing relay/DB handling. ## References - Spike notes: `RESEARCH/SPIKE_CHANNEL_VISIBILITY_TOGGLE.md` - Local validation: `cargo test -p buzz-cli` Generated with Codex Signed-off-by: Cameron Hotchkies <chotchkies@block.xyz> Co-authored-by: Lazy Joe <dbd8c9941ba6dafebcef0abc015b65e75d52e7452f2ce483c9c3fd4d180f2504@buzz.block.builderlab.xyz>
2504 lines
84 KiB
Rust
2504 lines
84 KiB
Rust
pub mod agent_management;
|
|
mod client;
|
|
mod commands;
|
|
mod error;
|
|
mod links;
|
|
mod validate;
|
|
|
|
use clap::{Parser, Subcommand};
|
|
use client::BuzzClient;
|
|
use error::CliError;
|
|
use nostr::Keys;
|
|
use uuid::Uuid;
|
|
|
|
/// Run the Buzz CLI from raw arguments (including `argv[0]`).
|
|
///
|
|
/// Returns a process exit code (0 = success).
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```ignore
|
|
/// let code = buzz_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,
|
|
{
|
|
// Install ring as the process-level rustls CryptoProvider. Required because the
|
|
// release workflow builds all binaries in one cargo invocation, which unifies
|
|
// features across the workspace and enables *both* ring (from buzz-acp/buzz-dev-mcp)
|
|
// and aws-lc-rs (from reqwest's rustls feature via hyper-rustls). With both on,
|
|
// rustls cannot auto-select a provider, and any code that reaches
|
|
// ClientConfig::builder() — specifically the WSS path in publish_ephemeral_event
|
|
// used by `agents draft-create`, `agents draft-update`, and `users set-presence`
|
|
// — panics at rustls crypto/mod.rs. The `let _ =` swallow is intentional: when
|
|
// buzz-dev-mcp delegates to run_from_args, it has already installed ring; the
|
|
// double-install returns Err and is harmless.
|
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "buzz",
|
|
about = "Buzz CLI — interact with a Buzz relay",
|
|
long_about = "\
|
|
Buzz CLI — interact with a Buzz relay
|
|
|
|
Configuration (flags override env vars):
|
|
BUZZ_RELAY_URL Relay base URL [default: http://localhost:3000]
|
|
BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [required]
|
|
BUZZ_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 5=write conflict
|
|
Errors are JSON on stderr: {\"error\": \"<category>\", \"message\": \"<detail>\"}"
|
|
)]
|
|
struct Cli {
|
|
/// Relay URL (http:// or https://). Overrides BUZZ_RELAY_URL env var.
|
|
#[arg(long, env = "BUZZ_RELAY_URL", default_value = "http://localhost:3000")]
|
|
relay: String,
|
|
|
|
/// Nostr private key (hex or nsec). This is the CLI's identity.
|
|
#[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)]
|
|
private_key: Option<String>,
|
|
|
|
/// NIP-OA auth tag JSON (owner attestation). Injected into every signed event.
|
|
#[arg(long, env = "BUZZ_AUTH_TAG", hide_env_values = true)]
|
|
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,
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
|
|
#[derive(Clone, clap::ValueEnum)]
|
|
pub enum EmojiScope {
|
|
#[value(name = "own")]
|
|
Own,
|
|
#[value(name = "workspace")]
|
|
Workspace,
|
|
}
|
|
|
|
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"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Cmd {
|
|
/// Draft owner-reviewed agent creation and updates
|
|
#[command(subcommand)]
|
|
Agents(AgentsCmd),
|
|
/// 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),
|
|
/// Manage your custom emoji set (workspace palette is the union of all members' sets)
|
|
#[command(subcommand)]
|
|
Emoji(EmojiCmd),
|
|
/// 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),
|
|
/// Publish and edit long-form NIP-23 notes — team knowledge base
|
|
#[command(subcommand)]
|
|
Notes(NotesCmd),
|
|
/// Announce and discover git repositories (NIP-34)
|
|
#[command(subcommand)]
|
|
Repos(ReposCmd),
|
|
/// Create and manage multi-repo projects (NIP-MP)
|
|
#[command(subcommand)]
|
|
Projects(ProjectsCmd),
|
|
/// Send, get, list, and set status on git patches (NIP-34)
|
|
#[command(subcommand)]
|
|
Patches(PatchesCmd),
|
|
/// Create, get, list, and set status on git issues (NIP-34)
|
|
#[command(subcommand)]
|
|
Issues(IssuesCmd),
|
|
/// Open, update, list, and set status on git pull requests (NIP-34)
|
|
#[command(subcommand)]
|
|
Pr(PrCmd),
|
|
/// Upload and download relay Blossom media
|
|
#[command(subcommand)]
|
|
Media(MediaCmd),
|
|
/// Upload files to the relay's Blossom store
|
|
#[command(subcommand)]
|
|
Upload(UploadCmd),
|
|
/// Agent engram management — persistent memory per NIP-AE
|
|
#[command(subcommand)]
|
|
Mem(MemCmd),
|
|
/// Persona pack operations (local, no relay connection needed)
|
|
#[command(subcommand)]
|
|
Pack(PackCmd),
|
|
/// Community moderation — reports queue, bans, timeouts, audit trail
|
|
#[command(subcommand)]
|
|
Moderation(ModerationCmd),
|
|
}
|
|
|
|
#[derive(Clone, Copy, clap::ValueEnum)]
|
|
pub enum RespondToArg {
|
|
#[value(name = "owner-only")]
|
|
OwnerOnly,
|
|
#[value(name = "anyone")]
|
|
Anyone,
|
|
}
|
|
|
|
impl RespondToArg {
|
|
fn to_wire(self) -> String {
|
|
match self {
|
|
Self::OwnerOnly => "owner-only",
|
|
Self::Anyone => "anyone",
|
|
}
|
|
.to_string()
|
|
}
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum AgentsCmd {
|
|
/// Open a prefilled create-agent form in the owner's Buzz Desktop
|
|
DraftCreate {
|
|
/// Current channel UUID; the new agent is added here after save
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// Proposed agent name
|
|
#[arg(long)]
|
|
display_name: String,
|
|
/// Proposed instructions; use '-' to read from stdin
|
|
#[arg(long)]
|
|
system_prompt: String,
|
|
},
|
|
/// Open a prefilled edit-agent form in the owner's Buzz Desktop
|
|
DraftUpdate {
|
|
/// Current channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// Current name of the personal agent to update
|
|
#[arg(long)]
|
|
agent_name: String,
|
|
#[arg(long)]
|
|
display_name: Option<String>,
|
|
/// Replacement instructions; use '-' to read from stdin
|
|
#[arg(long)]
|
|
system_prompt: Option<String>,
|
|
#[arg(long)]
|
|
runtime: Option<String>,
|
|
#[arg(long)]
|
|
provider: Option<String>,
|
|
#[arg(long)]
|
|
model: Option<String>,
|
|
#[arg(long, value_enum)]
|
|
respond_to: Option<RespondToArg>,
|
|
},
|
|
/// Submit a NIP-IA archive request for an identity (kind 9035)
|
|
#[command(
|
|
after_help = "Auth flow: when target != signer, the CLI fetches the target's kind:0 and \
|
|
attaches its owner-auth tag. On extraction failure it retries once (common cause: profile \
|
|
republish in progress). If the retry also fails, the command exits with an error — use \
|
|
--admin to bypass this guard when your key is a relay admin.\n\n\
|
|
Suggested --reason codes (unknown values are allowed): rotated, retired, \
|
|
bot-rebuilt, left-organization, spam\n\n\
|
|
Archiving a third-party identity is a human owner/admin action: an agent \
|
|
running under BUZZ_AUTH_TAG signs as itself, so it can only ever satisfy \
|
|
the self path (target == signer) — not the owner-of-agent path for another \
|
|
identity.\n\n\
|
|
Examples:\n \
|
|
buzz agents archive <PUBKEY> --reason retired\n \
|
|
buzz agents archive <PUBKEY> --reason bot-rebuilt --replaced-by <NEW_PUBKEY>"
|
|
)]
|
|
Archive {
|
|
/// Target identity pubkey (hex)
|
|
target_pubkey: String,
|
|
/// Machine-readable reason code, max 64 UTF-8 bytes
|
|
#[arg(long)]
|
|
reason: Option<String>,
|
|
/// Rotation pointer pubkey (hex); must differ from the target
|
|
#[arg(long)]
|
|
replaced_by: Option<String>,
|
|
/// Optional human-readable note (not parsed for authorization)
|
|
#[arg(long, default_value = "")]
|
|
content: String,
|
|
/// Allow sending without owner-auth attestation after extraction fails
|
|
/// (relay-admin path). Use only when your key is a relay admin; ordinary
|
|
/// owners do not need this flag. Without it, auth-extraction failure after
|
|
/// one automatic retry is a hard error rather than a silent bare send.
|
|
#[arg(long, default_value_t = false)]
|
|
admin: bool,
|
|
},
|
|
/// Submit a NIP-IA unarchive request for an identity (kind 9036)
|
|
#[command(
|
|
after_help = "Auth flow: same as `archive` — retries kind:0 fetch once on \
|
|
extraction failure, then exits with an error if still unresolvable. Use --admin to bypass \
|
|
for relay-admin callers.\n\n\
|
|
Examples:\n \
|
|
buzz agents unarchive <PUBKEY> --reason returned"
|
|
)]
|
|
Unarchive {
|
|
/// Target identity pubkey (hex)
|
|
target_pubkey: String,
|
|
/// Machine-readable reason code, max 64 UTF-8 bytes
|
|
#[arg(long)]
|
|
reason: Option<String>,
|
|
/// Optional human-readable note (not parsed for authorization)
|
|
#[arg(long, default_value = "")]
|
|
content: String,
|
|
/// Allow sending without owner-auth attestation after extraction fails
|
|
/// (relay-admin path). Use only when your key is a relay admin; ordinary
|
|
/// owners do not need this flag. Without it, auth-extraction failure after
|
|
/// one automatic retry is a hard error rather than a silent bare send.
|
|
#[arg(long, default_value_t = false)]
|
|
admin: bool,
|
|
},
|
|
/// Read the relay's current NIP-IA archive snapshot (kind 13535)
|
|
#[command(
|
|
after_help = "Verifies the snapshot's NIP-11 `self` authorship, event id, signature, \
|
|
and NIP-70 `-` protection tag before trusting it. Any trust failure is a \
|
|
nonzero-exit error, never a false-empty success — this command's whole \
|
|
purpose is verification.\n\n\
|
|
Examples:\n \
|
|
buzz agents archived"
|
|
)]
|
|
Archived,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum MessagesCmd {
|
|
/// Send a message to a channel
|
|
#[command(
|
|
after_help = "Examples:\n buzz messages send --channel <UUID> --content \"hello\"\n buzz messages send --channel <UUID> --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel <UUID> --content -"
|
|
)]
|
|
Send {
|
|
/// Channel UUID (from 'buzz channels list')
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// Message text — supports @mentions and markdown. Use '-' to read from stdin.
|
|
#[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,
|
|
/// Attach file(s) — uploads and includes as imeta tags
|
|
#[arg(long = "file")]
|
|
files: Vec<String>,
|
|
/// Pubkey to mention (hex or npub; repeatable). Supplying any explicit identity permits unresolved or ambiguous @Name text as presentation-only; uniquely resolved member names still notify.
|
|
#[arg(long = "mention")]
|
|
mentions: 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,
|
|
/// Optional moderation audit action UUID for the public tombstone
|
|
#[arg(long)]
|
|
action_id: Option<Uuid>,
|
|
/// Optional machine-readable public reason code for the tombstone
|
|
#[arg(long)]
|
|
reason_code: Option<String>,
|
|
/// Optional human-readable public reason for the tombstone
|
|
#[arg(long)]
|
|
public_reason: Option<String>,
|
|
},
|
|
/// Retrieve messages from a channel
|
|
#[command(
|
|
after_help = "Examples:\n buzz messages get --channel <UUID>\n buzz 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 number of results to return
|
|
#[arg(long)]
|
|
limit: Option<u32>,
|
|
/// Maximum reply nesting depth to include
|
|
#[arg(long)]
|
|
depth_limit: Option<u32>,
|
|
},
|
|
/// Full-text search across messages
|
|
#[command(
|
|
after_help = "Examples:\n buzz messages search --query checkout\n buzz messages search --author npub1... --since 1783497600\n buzz messages search --author Aaron --query checkout --limit 20"
|
|
)]
|
|
Search {
|
|
/// Search query string (optional when --author is given)
|
|
#[arg(long)]
|
|
query: Option<String>,
|
|
/// Filter by author: 64-char hex pubkey, npub, or display name
|
|
#[arg(long)]
|
|
author: Option<String>,
|
|
/// Unix timestamp — return messages after this time
|
|
#[arg(long)]
|
|
since: Option<i64>,
|
|
/// 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,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum ChannelsCmd {
|
|
/// List channels visible to the current identity
|
|
#[command(
|
|
after_help = "Examples:\n buzz channels list\n buzz 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,
|
|
/// Maximum number of channels to return [default: 500]
|
|
#[arg(long)]
|
|
limit: Option<u32>,
|
|
},
|
|
/// Get details for a single channel
|
|
Get {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
},
|
|
/// Search channels by human-readable name
|
|
#[command(
|
|
after_help = "Examples:\n buzz channels search --query composer\n buzz channels search --query buzz-chat-composer --exact\n buzz channels search --query design --include-archived"
|
|
)]
|
|
Search {
|
|
/// Search query (case-insensitive substring of channel name)
|
|
#[arg(long)]
|
|
query: String,
|
|
/// Require an exact case-insensitive match instead of substring
|
|
#[arg(long, default_value_t = false)]
|
|
exact: bool,
|
|
/// Include archived channels in results
|
|
#[arg(long, default_value_t = false)]
|
|
include_archived: bool,
|
|
/// Maximum number of channel-metadata events to fetch from the relay
|
|
#[arg(long, default_value_t = 1000)]
|
|
limit: u32,
|
|
},
|
|
/// Create a new channel
|
|
#[command(
|
|
after_help = "Examples:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name design --type forum --visibility open --description \"Design discussions\"\n buzz channels create --name standup --type stream --visibility open --ttl 3600 # ephemeral, archived after 1h idle\n buzz channels create --name project-x --template \"Buzz Team\" # type/visibility/canvas/roster from the template; explicit flags override"
|
|
)]
|
|
Create {
|
|
/// Channel name
|
|
#[arg(long)]
|
|
name: String,
|
|
/// Channel type. Required unless --template supplies one.
|
|
#[arg(long = "type", value_enum, required_unless_present = "template")]
|
|
channel_type: Option<ChannelType>,
|
|
/// Channel visibility. Required unless --template supplies one.
|
|
#[arg(long, value_enum, required_unless_present = "template")]
|
|
visibility: Option<ChannelVisibility>,
|
|
/// Channel description
|
|
#[arg(long)]
|
|
description: Option<String>,
|
|
/// Make the channel ephemeral: lifetime in seconds. The relay archives
|
|
/// it once this many seconds pass without a new message.
|
|
#[arg(long, value_name = "SECONDS")]
|
|
ttl: Option<i64>,
|
|
/// Apply a desktop-local channel template by name (case-insensitive):
|
|
/// supplies default type/visibility/description/canvas, and resolves
|
|
/// its agent roster against the relay to add as members.
|
|
#[arg(long)]
|
|
template: Option<String>,
|
|
/// Override the channel-templates.json path (default: the desktop
|
|
/// app's prod app-data dir). Mainly for the dev store or testing.
|
|
#[arg(long, value_name = "PATH")]
|
|
templates_file: Option<String>,
|
|
},
|
|
/// Update channel name, description, visibility, or ephemeral TTL
|
|
#[command(
|
|
after_help = "Examples:\n buzz channels update --channel <uuid> --name general\n buzz channels update --channel <uuid> --visibility open\n buzz channels update --channel <uuid> --visibility private"
|
|
)]
|
|
Update {
|
|
/// Channel UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
/// New channel name
|
|
#[arg(long)]
|
|
name: Option<String>,
|
|
/// New channel description
|
|
#[arg(long)]
|
|
description: Option<String>,
|
|
/// New channel visibility
|
|
#[arg(long, value_enum)]
|
|
visibility: Option<ChannelVisibility>,
|
|
/// Make the channel ephemeral (or change its lifetime): seconds until
|
|
/// the relay archives it after the last message. Conflicts with --no-ttl.
|
|
#[arg(long, value_name = "SECONDS", conflicts_with = "no_ttl")]
|
|
ttl: Option<i64>,
|
|
/// Clear an existing TTL, making the channel permanent.
|
|
#[arg(long)]
|
|
no_ttl: bool,
|
|
},
|
|
/// 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,
|
|
},
|
|
/// Set your channel addition policy
|
|
#[command(name = "set-add-policy")]
|
|
SetAddPolicy {
|
|
/// Policy: anyone | owner_only | nobody
|
|
#[arg(long)]
|
|
policy: String,
|
|
},
|
|
}
|
|
|
|
#[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,
|
|
},
|
|
}
|
|
|
|
#[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. '👍') or custom emoji shortcode
|
|
#[arg(long)]
|
|
emoji: String,
|
|
/// Image URL for a custom emoji reaction; when set, content becomes `:shortcode:`
|
|
#[arg(long = "emoji-url")]
|
|
emoji_url: Option<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,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum EmojiCmd {
|
|
/// List the workspace custom emoji palette (union of every member's set)
|
|
List,
|
|
/// Add or update a custom emoji in your own set
|
|
Set {
|
|
/// Emoji shortcode, without surrounding colons
|
|
#[arg(long)]
|
|
shortcode: String,
|
|
/// Image URL for the emoji
|
|
#[arg(long)]
|
|
url: String,
|
|
},
|
|
/// Remove a custom emoji from your own set
|
|
Rm {
|
|
/// Emoji shortcode, without surrounding colons
|
|
#[arg(long)]
|
|
shortcode: String,
|
|
},
|
|
/// Export custom emojis to stdout or a file
|
|
Export {
|
|
/// Write JSON to this file path instead of stdout
|
|
#[arg(long)]
|
|
file: Option<String>,
|
|
/// Export your own set (default) or the full workspace palette
|
|
#[arg(long, value_enum, default_value = "own")]
|
|
scope: EmojiScope,
|
|
},
|
|
/// Import custom emojis from stdin or a file into your own set
|
|
Import {
|
|
/// Read JSON from this file path instead of stdin
|
|
#[arg(long)]
|
|
file: Option<String>,
|
|
/// Replace your entire set instead of merging
|
|
#[arg(long, default_value_t = false)]
|
|
replace: bool,
|
|
/// Print what would be published without writing
|
|
#[arg(long, default_value_t = false)]
|
|
dry_run: bool,
|
|
},
|
|
}
|
|
|
|
#[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,
|
|
},
|
|
/// Hide a DM conversation from your DM list
|
|
Hide {
|
|
/// DM conversation UUID
|
|
#[arg(long)]
|
|
channel: String,
|
|
},
|
|
}
|
|
|
|
#[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>,
|
|
/// Scope an exact-name agent lookup to its owner (`me`, hex, or npub)
|
|
#[arg(long = "owner", requires = "name")]
|
|
owner: 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,
|
|
},
|
|
/// Set your user status (NIP-38 kind:30315 — the "status" line on your profile)
|
|
#[command(name = "set-status")]
|
|
SetStatus {
|
|
/// Status text (required unless --clear)
|
|
#[arg(long, required_unless_present = "clear")]
|
|
text: Option<String>,
|
|
/// Optional emoji shown before the status text
|
|
#[arg(long)]
|
|
emoji: Option<String>,
|
|
/// Remove your status entirely
|
|
#[arg(long, conflicts_with_all = ["text", "emoji"])]
|
|
clear: bool,
|
|
},
|
|
}
|
|
|
|
#[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 buzz workflows trigger --workflow <UUID>\n buzz workflows trigger --workflow <UUID> --inputs '{\"key\":\"value\"}'"
|
|
)]
|
|
Trigger {
|
|
/// Workflow UUID
|
|
#[arg(long)]
|
|
workflow: String,
|
|
/// JSON object of input variables passed to the workflow as event content
|
|
#[arg(long)]
|
|
inputs: Option<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 buzz workflows approve --token <UUID>\n buzz 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>,
|
|
},
|
|
}
|
|
|
|
#[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 types to include: mentions, needs_action, activity, agent_activity
|
|
#[arg(long)]
|
|
types: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[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>,
|
|
/// Event ID cursor — return notes created before this event (composite pagination with --before).
|
|
#[arg(long)]
|
|
before_id: Option<String>,
|
|
},
|
|
/// Get a user's contact list
|
|
#[command(name = "contacts")]
|
|
GetContactList {
|
|
/// 64-char hex pubkey.
|
|
#[arg(long)]
|
|
pubkey: String,
|
|
},
|
|
/// Publish a NIP-51/NIP-65 social list or set.
|
|
#[command(name = "set-list")]
|
|
SetList {
|
|
/// Supported kind: 10000, 10001, 10002, 10003, 30000, or 30003.
|
|
#[arg(long)]
|
|
kind: u16,
|
|
/// JSON array of Nostr tags, e.g. [["p","<hex>"],["d","friends"]].
|
|
#[arg(long)]
|
|
tags: String,
|
|
/// Event content.
|
|
#[arg(long, default_value = "")]
|
|
content: String,
|
|
},
|
|
/// Get NIP-51/NIP-65 social lists or sets by author and kind.
|
|
#[command(name = "list")]
|
|
GetList {
|
|
/// 64-char hex pubkey of the author.
|
|
#[arg(long)]
|
|
pubkey: String,
|
|
/// Supported kind: 10000, 10001, 10002, 10003, 30000, or 30003.
|
|
#[arg(long)]
|
|
kind: u32,
|
|
/// Optional d-tag for parameterized replaceable sets.
|
|
#[arg(long)]
|
|
d_tag: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum NotesCmd {
|
|
/// Create or update a note. Idempotent upsert keyed by `(me, --name)`.
|
|
///
|
|
/// `published_at` is preserved on edits (only set on first create).
|
|
/// `--title` is required on first create; on subsequent edits the existing
|
|
/// title is carried forward when `--title` is omitted, and `--title ""`
|
|
/// explicitly clears it.
|
|
#[command(
|
|
after_help = "Examples:\n echo '# Hello' | buzz notes set --name hello --title 'Hello' --content -\n buzz notes set --name hello --tag onboarding --content - < draft.md"
|
|
)]
|
|
Set {
|
|
/// Slug — becomes the `d` tag. `[a-z0-9._-]{1,80}`.
|
|
#[arg(long)]
|
|
name: String,
|
|
/// Note title (NIP-23 `title` tag). Required on first create; omit to carry; `""` to clear.
|
|
#[arg(long)]
|
|
title: Option<String>,
|
|
/// Short summary (NIP-23 `summary` tag). Omit to carry; `""` to clear.
|
|
#[arg(long)]
|
|
summary: Option<String>,
|
|
/// Topic tag (NIP-23 `t` tag). May be repeated. Replaces (not merges) existing tags on edit; omit to carry forward.
|
|
#[arg(long = "tag")]
|
|
tags: Vec<String>,
|
|
/// Clear all `t` tags on update. Mutually exclusive with `--tag`.
|
|
/// Without this and without `--tag`, existing tags are carried forward.
|
|
#[arg(long, default_value_t = false)]
|
|
clear_tags: bool,
|
|
/// Markdown body. Use `-` to read from stdin.
|
|
#[arg(long)]
|
|
content: String,
|
|
/// Allow committing an empty body (refused by default to catch upstream pipeline failures).
|
|
#[arg(long, default_value_t = false)]
|
|
allow_empty: bool,
|
|
},
|
|
/// Read a note by `--naddr` (exact) or `--name <slug>` (cross-author lookup).
|
|
Get {
|
|
/// NIP-19 `naddr1…` or `30023:<pubkey>:<slug>` coordinate. Mutually exclusive with `--name`.
|
|
#[arg(long)]
|
|
naddr: Option<String>,
|
|
/// Slug to look up across authors. Mutually exclusive with `--naddr`.
|
|
#[arg(long)]
|
|
name: Option<String>,
|
|
/// Disambiguate `--name` to a specific author (hex pubkey, display name, or `me`).
|
|
#[arg(long)]
|
|
author: Option<String>,
|
|
/// On an ambiguous `--name` (multiple authors), pick the most recently updated note
|
|
/// instead of erroring. Mutually exclusive with `--author` and `--naddr`.
|
|
#[arg(long, default_value_t = false)]
|
|
latest: bool,
|
|
/// Print only the markdown body, not the full event JSON.
|
|
#[arg(long, default_value_t = false)]
|
|
content_only: bool,
|
|
},
|
|
/// List notes. Defaults to your own.
|
|
Ls {
|
|
/// Hex pubkey, display name, `me`, or `all`.
|
|
#[arg(long, default_value = "me")]
|
|
author: Option<String>,
|
|
/// Filter by NIP-23 `t` tag.
|
|
#[arg(long)]
|
|
tag: Option<String>,
|
|
/// Max results (default 50, hard cap 200).
|
|
#[arg(long)]
|
|
limit: Option<u32>,
|
|
},
|
|
/// Delete one of your own notes via NIP-09 (kind:5).
|
|
///
|
|
/// Emits an a-tag-only deletion targeting the addressable coordinate
|
|
/// `30023:<pubkey>:<slug>` (no `e` tag — an `e` tag would route around the
|
|
/// relay's coordinate soft-delete and leave the note alive). Read-before-
|
|
/// write gives a clean NotFound when there's nothing to delete.
|
|
Rm {
|
|
/// Slug of the note to delete. Only your own notes can be removed.
|
|
#[arg(long)]
|
|
name: String,
|
|
},
|
|
}
|
|
|
|
#[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>,
|
|
/// Channel UUID to bind the repo to. The `buzz-channel` tag is the
|
|
/// git ACL: without it the relay 404s every clone/fetch/push until
|
|
/// the author runs `buzz repos bind` (issue #3527).
|
|
#[arg(long)]
|
|
channel: Option<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>,
|
|
},
|
|
/// Bind (or rebind) one of your repositories to a channel.
|
|
///
|
|
/// The `buzz-channel` tag on the announcement is the git ACL: the relay
|
|
/// authorizes clone/fetch/push by membership in the bound channel. A
|
|
/// repo announced without it (e.g. by a vanilla NIP-34 client) returns
|
|
/// 404 for everyone until its author binds it here.
|
|
Bind {
|
|
/// Repository identifier (d-tag).
|
|
#[arg(long)]
|
|
id: String,
|
|
/// Channel UUID to bind. Replaces any existing binding.
|
|
#[arg(long)]
|
|
channel: String,
|
|
},
|
|
/// Manage branch and tag protection rules on one of your repositories.
|
|
#[command(subcommand)]
|
|
Protect(ReposProtectCmd),
|
|
}
|
|
|
|
/// Commands for inspecting and changing repository protection rules.
|
|
#[derive(Subcommand)]
|
|
pub enum ReposProtectCmd {
|
|
/// List the repository's protection rules.
|
|
List {
|
|
/// Repository identifier (d-tag).
|
|
#[arg(long)]
|
|
id: String,
|
|
},
|
|
/// Create or replace the rule for an exact ref pattern.
|
|
Set {
|
|
/// Repository identifier (d-tag).
|
|
#[arg(long)]
|
|
id: String,
|
|
/// Full ref pattern, such as refs/heads/main or refs/heads/*.
|
|
#[arg(long = "ref")]
|
|
ref_pattern: String,
|
|
/// Minimum role allowed to push.
|
|
#[arg(long)]
|
|
push: Option<RepoPushRole>,
|
|
/// Reject non-fast-forward updates.
|
|
#[arg(long, default_value_t = false)]
|
|
no_force_push: bool,
|
|
/// Reject deletion of matching refs.
|
|
#[arg(long, default_value_t = false)]
|
|
no_delete: bool,
|
|
/// Require the NIP-34 patch workflow instead of direct pushes.
|
|
#[arg(long, default_value_t = false)]
|
|
require_patch: bool,
|
|
},
|
|
/// Remove every protection rule for an exact ref pattern.
|
|
Remove {
|
|
/// Repository identifier (d-tag).
|
|
#[arg(long)]
|
|
id: String,
|
|
/// Full ref pattern to remove.
|
|
#[arg(long = "ref")]
|
|
ref_pattern: String,
|
|
},
|
|
}
|
|
|
|
/// Minimum channel role accepted by a repository push rule.
|
|
#[derive(Clone, Copy, clap::ValueEnum)]
|
|
pub enum RepoPushRole {
|
|
/// Repository owner only.
|
|
Owner,
|
|
/// Repository owner or channel admin.
|
|
Admin,
|
|
/// Any channel member.
|
|
Member,
|
|
}
|
|
|
|
/// Visibility of a multi-repo project listing.
|
|
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
|
|
pub enum ProjectVisibility {
|
|
/// Project appears in public listings (default).
|
|
Listed,
|
|
/// Project is hidden from public listings.
|
|
Unlisted,
|
|
}
|
|
|
|
impl ProjectVisibility {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
ProjectVisibility::Listed => "listed",
|
|
ProjectVisibility::Unlisted => "unlisted",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum ProjectsCmd {
|
|
/// Create a new multi-repo project (NIP-MP kind:30621)
|
|
///
|
|
/// Requires at least one --repo. Fails with Conflict if the project already exists.
|
|
Create {
|
|
/// Project identifier (slug), up to 1024 bytes
|
|
slug: String,
|
|
/// Member repository coordinate: bare Buzz repo id (e.g. `buzz`) or full
|
|
/// `30617:<owner-hex>:<repo-d>` for cross-owner or colon-bearing repo ids.
|
|
/// At least one --repo is required.
|
|
#[arg(long = "repo", required = true)]
|
|
repo: Vec<String>,
|
|
/// Display name (≤256 bytes)
|
|
#[arg(long)]
|
|
name: Option<String>,
|
|
/// Description (≤2048 bytes)
|
|
#[arg(long)]
|
|
description: Option<String>,
|
|
/// Associated Buzz channel UUID
|
|
#[arg(long)]
|
|
channel: Option<String>,
|
|
/// Visibility: `listed` (default) or `unlisted`
|
|
#[arg(long)]
|
|
visibility: Option<ProjectVisibility>,
|
|
},
|
|
/// Get a project by slug
|
|
Get {
|
|
/// Project slug
|
|
slug: String,
|
|
/// Owner pubkey (64-char hex). Defaults to the current identity.
|
|
#[arg(long)]
|
|
owner: Option<String>,
|
|
},
|
|
/// List projects
|
|
List {
|
|
/// Owner pubkey (64-char hex). Defaults to the current identity.
|
|
#[arg(long)]
|
|
owner: Option<String>,
|
|
/// Maximum number of results
|
|
#[arg(long)]
|
|
limit: Option<u32>,
|
|
},
|
|
/// Add one or more member repositories to a project
|
|
#[command(name = "add-repo")]
|
|
AddRepo {
|
|
/// Project slug
|
|
slug: String,
|
|
/// Member repository coordinate (bare id or full `30617:<owner-hex>:<repo-d>`)
|
|
#[arg(long = "repo", required = true)]
|
|
repo: Vec<String>,
|
|
},
|
|
/// Remove one or more member repositories from a project
|
|
#[command(name = "remove-repo")]
|
|
RemoveRepo {
|
|
/// Project slug
|
|
slug: String,
|
|
/// Member repository coordinate to remove (bare id or full `30617:<owner-hex>:<repo-d>`)
|
|
#[arg(long = "repo", required = true)]
|
|
repo: Vec<String>,
|
|
},
|
|
/// Update project metadata (at least one setter or clearer required)
|
|
#[command(group = clap::ArgGroup::new("mutation").required(true).multiple(true))]
|
|
Update {
|
|
/// Project slug
|
|
slug: String,
|
|
/// Set the display name
|
|
#[arg(long, group = "mutation")]
|
|
name: Option<String>,
|
|
/// Remove the display name
|
|
#[arg(long, group = "mutation", conflicts_with = "name")]
|
|
clear_name: bool,
|
|
/// Set the description
|
|
#[arg(long, group = "mutation")]
|
|
description: Option<String>,
|
|
/// Remove the description
|
|
#[arg(long, group = "mutation", conflicts_with = "description")]
|
|
clear_description: bool,
|
|
/// Set the associated Buzz channel UUID
|
|
#[arg(long, group = "mutation")]
|
|
channel: Option<String>,
|
|
/// Remove the associated channel
|
|
#[arg(long, group = "mutation", conflicts_with = "channel")]
|
|
clear_channel: bool,
|
|
/// Set visibility: `listed` or `unlisted`
|
|
#[arg(long, group = "mutation")]
|
|
visibility: Option<ProjectVisibility>,
|
|
/// Remove the visibility tag (absence defaults to `listed`)
|
|
#[arg(long, group = "mutation", conflicts_with = "visibility")]
|
|
clear_visibility: bool,
|
|
},
|
|
/// Delete a project (head-based tombstone; verified after submit)
|
|
Delete {
|
|
/// Project slug
|
|
slug: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum PatchesCmd {
|
|
/// Send a git patch (NIP-34 kind:1617)
|
|
#[command(
|
|
after_help = "Examples:\n git format-patch -1 HEAD --stdout | buzz patches send --repo-owner <hex> --repo-id myrepo --patch-file - --root\n buzz patches send --repo-owner <hex> --repo-id myrepo --patch-file 0001-fix.patch --reply-to <prev-patch-id>"
|
|
)]
|
|
Send {
|
|
/// Repo owner pubkey (64-char hex)
|
|
#[arg(long)]
|
|
repo_owner: String,
|
|
/// Repo identifier (d-tag)
|
|
#[arg(long)]
|
|
repo_id: String,
|
|
/// Path to a `git format-patch` file, or '-' to read from stdin
|
|
#[arg(long)]
|
|
patch_file: String,
|
|
/// Earliest-unique-commit of the repo
|
|
#[arg(long)]
|
|
euc: Option<String>,
|
|
/// Additional recipient pubkey(s) — can be specified multiple times
|
|
#[arg(long = "to")]
|
|
to: Vec<String>,
|
|
/// Previous patch event id (series) or original root (revision)
|
|
#[arg(long)]
|
|
reply_to: Option<String>,
|
|
/// Mark as the first patch of a new series
|
|
#[arg(long, default_value_t = false)]
|
|
root: bool,
|
|
/// Mark as the first patch of a new revision of an existing series
|
|
#[arg(long, default_value_t = false)]
|
|
root_revision: bool,
|
|
/// Commit ID this patch produces when applied
|
|
#[arg(long)]
|
|
commit: Option<String>,
|
|
/// Parent commit ID
|
|
#[arg(long)]
|
|
parent_commit: Option<String>,
|
|
/// PGP signature of the commit
|
|
#[arg(long)]
|
|
commit_pgp_sig: Option<String>,
|
|
/// Committer identity: 'name|email|timestamp|tz-offset-minutes'
|
|
#[arg(long)]
|
|
committer: Option<String>,
|
|
},
|
|
/// Get a patch by event id
|
|
Get {
|
|
/// Patch event id (64-char hex)
|
|
#[arg(long)]
|
|
event: String,
|
|
},
|
|
/// List patches for a repo
|
|
List {
|
|
/// Repo owner pubkey (64-char hex)
|
|
#[arg(long)]
|
|
repo_owner: String,
|
|
/// Repo identifier (d-tag)
|
|
#[arg(long)]
|
|
repo_id: String,
|
|
/// Filter by patch author pubkey
|
|
#[arg(long)]
|
|
author: Option<String>,
|
|
/// Maximum number of results
|
|
#[arg(long)]
|
|
limit: Option<u32>,
|
|
},
|
|
/// Set status on a patch (open/merged/closed/draft — NIP-34 kind:1630-1633)
|
|
Status {
|
|
/// Root patch event id (first patch of the series/revision)
|
|
#[arg(long)]
|
|
root: String,
|
|
/// New status
|
|
#[arg(long, value_parser = ["open", "merged", "closed", "draft"])]
|
|
status: String,
|
|
/// Markdown context for the status change ('-' to read from stdin)
|
|
#[arg(long)]
|
|
content: Option<String>,
|
|
/// Repo owner pubkey — requires --repo-id
|
|
#[arg(long, requires = "repo_id")]
|
|
repo_owner: Option<String>,
|
|
/// Repo identifier (d-tag) — requires --repo-owner
|
|
#[arg(long, requires = "repo_owner")]
|
|
repo_id: Option<String>,
|
|
/// Earliest-unique-commit of the repo
|
|
#[arg(long)]
|
|
euc: Option<String>,
|
|
/// Root id of the revision that was accepted (status=merged only)
|
|
#[arg(long)]
|
|
revision: Option<String>,
|
|
/// Additional recipient pubkey(s) for the status event (besides the
|
|
/// repo owner, which is tagged automatically when --repo-owner is
|
|
/// given) — e.g. root/revision author. Can be specified multiple times.
|
|
#[arg(long = "to")]
|
|
to: Vec<String>,
|
|
/// Applied patch event id — can be specified multiple times (status=merged only).
|
|
/// Accepts `<id>`, `<id>:<relay-url>`, or `<id>:<relay-url>:<pubkey>`.
|
|
#[arg(long = "q")]
|
|
q: Vec<String>,
|
|
/// Merge commit id (status=merged only)
|
|
#[arg(long)]
|
|
merge_commit: Option<String>,
|
|
/// Commit id applied to the target branch — can be specified multiple times (status=merged only)
|
|
#[arg(long = "applied-as-commit")]
|
|
applied_as_commit: Vec<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum PrCmd {
|
|
/// Open a git pull request (NIP-34 kind:1618)
|
|
#[command(
|
|
after_help = "Examples:\n buzz pr open --repo-owner <hex> --repo-id myrepo --subject 'Fix bug' --body-file - --commit $(git rev-parse HEAD) --clone https://relay/git/owner/myrepo --branch-name fix-bug\n buzz pr update --repo-owner <hex> --repo-id myrepo --pr <event> --pr-author <hex> --commit $(git rev-parse HEAD) --clone https://relay/git/owner/myrepo"
|
|
)]
|
|
Open {
|
|
/// Repo owner pubkey (64-char hex)
|
|
#[arg(long)]
|
|
repo_owner: String,
|
|
/// Repo identifier (d-tag)
|
|
#[arg(long)]
|
|
repo_id: String,
|
|
/// Pull request subject/header
|
|
#[arg(long, alias = "title")]
|
|
subject: String,
|
|
/// Pull request body markdown. Use '-' to read from stdin.
|
|
#[arg(long, conflicts_with = "body_file")]
|
|
body: Option<String>,
|
|
/// Path to pull request body markdown, or '-' to read from stdin.
|
|
#[arg(long, conflicts_with = "body")]
|
|
body_file: Option<String>,
|
|
/// Tip commit of the PR branch
|
|
#[arg(long)]
|
|
commit: String,
|
|
/// Clone URL where the tip commit can be fetched — can be specified multiple times
|
|
#[arg(long = "clone", required = true)]
|
|
clone: Vec<String>,
|
|
/// Recommended branch name
|
|
#[arg(long)]
|
|
branch_name: Option<String>,
|
|
/// Most recent common ancestor with the target branch
|
|
#[arg(long)]
|
|
merge_base: Option<String>,
|
|
/// Earliest-unique-commit of the repo
|
|
#[arg(long)]
|
|
euc: Option<String>,
|
|
/// Label — can be specified multiple times
|
|
#[arg(long = "label")]
|
|
label: Vec<String>,
|
|
/// Additional recipient pubkey(s) — can be specified multiple times
|
|
#[arg(long = "to")]
|
|
to: Vec<String>,
|
|
/// Channel where this pull request originated (NIP-29 h-tag)
|
|
#[arg(long)]
|
|
channel: Option<String>,
|
|
/// Root patch event id this PR revises
|
|
#[arg(long)]
|
|
revision_of: Option<String>,
|
|
},
|
|
/// Update a git pull request tip (NIP-34 kind:1619)
|
|
Update {
|
|
/// Repo owner pubkey (64-char hex)
|
|
#[arg(long)]
|
|
repo_owner: String,
|
|
/// Repo identifier (d-tag)
|
|
#[arg(long)]
|
|
repo_id: String,
|
|
/// Pull request event id being updated
|
|
#[arg(long)]
|
|
pr: String,
|
|
/// Pull request author's pubkey
|
|
#[arg(long)]
|
|
pr_author: String,
|
|
/// Updated tip commit of the PR branch
|
|
#[arg(long)]
|
|
commit: String,
|
|
/// Clone URL where the updated tip commit can be fetched — can be specified multiple times
|
|
#[arg(long = "clone", required = true)]
|
|
clone: Vec<String>,
|
|
/// Markdown context for the update. Use '-' to read from stdin.
|
|
#[arg(long, conflicts_with = "body_file")]
|
|
body: Option<String>,
|
|
/// Path to markdown context for the update, or '-' to read from stdin.
|
|
#[arg(long, conflicts_with = "body")]
|
|
body_file: Option<String>,
|
|
/// Most recent common ancestor with the target branch
|
|
#[arg(long)]
|
|
merge_base: Option<String>,
|
|
/// Earliest-unique-commit of the repo
|
|
#[arg(long)]
|
|
euc: Option<String>,
|
|
/// Additional recipient pubkey(s) — can be specified multiple times
|
|
#[arg(long = "to")]
|
|
to: Vec<String>,
|
|
},
|
|
/// Get a PR by event id
|
|
Get {
|
|
/// PR event id (64-char hex)
|
|
#[arg(long)]
|
|
event: String,
|
|
},
|
|
/// List PRs for a repo
|
|
List {
|
|
/// Repo owner pubkey (64-char hex)
|
|
#[arg(long)]
|
|
repo_owner: String,
|
|
/// Repo identifier (d-tag)
|
|
#[arg(long)]
|
|
repo_id: String,
|
|
/// Filter by PR author pubkey
|
|
#[arg(long)]
|
|
author: Option<String>,
|
|
/// Filter by label
|
|
#[arg(long)]
|
|
label: Option<String>,
|
|
/// Maximum number of results
|
|
#[arg(long)]
|
|
limit: Option<u32>,
|
|
},
|
|
/// Set status on a PR (open/merged/closed/draft — NIP-34 kind:1630-1633)
|
|
Status {
|
|
/// Pull request event id
|
|
#[arg(long)]
|
|
pr: String,
|
|
/// New status
|
|
#[arg(long, value_parser = ["open", "merged", "closed", "draft"])]
|
|
status: String,
|
|
/// Markdown context for the status change. Use '-' to read from stdin.
|
|
#[arg(long, conflicts_with = "body_file")]
|
|
body: Option<String>,
|
|
/// Path to markdown context for the status change, or '-' to read from stdin.
|
|
#[arg(long, conflicts_with = "body")]
|
|
body_file: Option<String>,
|
|
/// Repo owner pubkey — requires --repo-id
|
|
#[arg(long, requires = "repo_id")]
|
|
repo_owner: Option<String>,
|
|
/// Repo identifier (d-tag) — requires --repo-owner
|
|
#[arg(long, requires = "repo_owner")]
|
|
repo_id: Option<String>,
|
|
/// Earliest-unique-commit of the repo
|
|
#[arg(long)]
|
|
euc: Option<String>,
|
|
/// Additional recipient pubkey(s) for the status event (besides the
|
|
/// repo owner, which is tagged automatically when --repo-owner is
|
|
/// given) — e.g. PR author/reviewers. Can be specified multiple times.
|
|
#[arg(long = "to")]
|
|
to: Vec<String>,
|
|
/// Merge commit id (status=merged only)
|
|
#[arg(long)]
|
|
merge_commit: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum IssuesCmd {
|
|
/// Create a git issue (NIP-34 kind:1621)
|
|
Create {
|
|
/// Repo owner pubkey (64-char hex)
|
|
#[arg(long)]
|
|
repo_owner: String,
|
|
/// Repo identifier (d-tag)
|
|
#[arg(long)]
|
|
repo_id: String,
|
|
/// Issue title
|
|
#[arg(long, alias = "subject")]
|
|
title: String,
|
|
/// Issue body, markdown. Use '-' to read from stdin.
|
|
#[arg(long)]
|
|
content: String,
|
|
/// Label — can be specified multiple times
|
|
#[arg(long = "label")]
|
|
label: Vec<String>,
|
|
/// Additional recipient pubkey(s) — can be specified multiple times
|
|
#[arg(long = "to")]
|
|
to: Vec<String>,
|
|
},
|
|
/// Get an issue by event id
|
|
Get {
|
|
/// Issue event id (64-char hex)
|
|
#[arg(long)]
|
|
event: String,
|
|
},
|
|
/// List issues for a repo
|
|
List {
|
|
/// Repo owner pubkey (64-char hex)
|
|
#[arg(long)]
|
|
repo_owner: String,
|
|
/// Repo identifier (d-tag)
|
|
#[arg(long)]
|
|
repo_id: String,
|
|
/// Filter by issue author pubkey
|
|
#[arg(long)]
|
|
author: Option<String>,
|
|
/// Filter by label
|
|
#[arg(long)]
|
|
label: Option<String>,
|
|
/// Maximum number of results
|
|
#[arg(long)]
|
|
limit: Option<u32>,
|
|
},
|
|
/// Set status on an issue (open/resolved/closed/draft — NIP-34 kind:1630-1633)
|
|
Status {
|
|
/// Issue event id
|
|
#[arg(long)]
|
|
issue: String,
|
|
/// New status
|
|
#[arg(long, value_parser = ["open", "resolved", "closed", "draft"])]
|
|
status: String,
|
|
/// Markdown context for the status change ('-' to read from stdin)
|
|
#[arg(long)]
|
|
content: Option<String>,
|
|
/// Repo owner pubkey — requires --repo-id
|
|
#[arg(long, requires = "repo_id")]
|
|
repo_owner: Option<String>,
|
|
/// Repo identifier (d-tag) — requires --repo-owner
|
|
#[arg(long, requires = "repo_owner")]
|
|
repo_id: Option<String>,
|
|
/// Earliest-unique-commit of the repo
|
|
#[arg(long)]
|
|
euc: Option<String>,
|
|
/// Additional recipient pubkey(s) for the status event (besides the
|
|
/// repo owner, which is tagged automatically when --repo-owner is
|
|
/// given) — e.g. the issue author. Can be specified multiple times.
|
|
#[arg(long = "to")]
|
|
to: Vec<String>,
|
|
},
|
|
}
|
|
|
|
#[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,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
pub enum MediaCmd {
|
|
/// Download relay media with Blossom get auth
|
|
Get {
|
|
/// Relay media URL or sha256[.ext] path segment
|
|
input: String,
|
|
/// Output path. Omit or use '-' to write raw bytes to stdout.
|
|
#[arg(short, long)]
|
|
output: Option<String>,
|
|
},
|
|
}
|
|
|
|
/// Subcommands for `buzz mem`.
|
|
#[derive(Subcommand)]
|
|
pub enum MemCmd {
|
|
/// List non-tombstoned memory entries
|
|
Ls {
|
|
/// Owner pubkey (hex). Overrides BUZZ_AUTH_TAG.
|
|
#[arg(long)]
|
|
owner: Option<String>,
|
|
/// Agent pubkey (hex) to read as this key's owner.
|
|
#[arg(long)]
|
|
agent: Option<String>,
|
|
/// Emit JSON instead of tab-delimited lines.
|
|
#[arg(long, default_value_t = false)]
|
|
json: bool,
|
|
},
|
|
/// Print the value of a slug to stdout (no trailing newline)
|
|
Get {
|
|
slug: String,
|
|
#[arg(long)]
|
|
owner: Option<String>,
|
|
/// Agent pubkey (hex) to read as this key's owner.
|
|
#[arg(long)]
|
|
agent: Option<String>,
|
|
},
|
|
/// Print sha256(value) in hex (use as `--base-hash` for `mem patch`).
|
|
Hash {
|
|
slug: String,
|
|
#[arg(long)]
|
|
owner: Option<String>,
|
|
/// Agent pubkey (hex) to read as this key's owner.
|
|
#[arg(long)]
|
|
agent: Option<String>,
|
|
},
|
|
/// Set a slug's value. Pass `-` to read the value from stdin.
|
|
Set {
|
|
slug: String,
|
|
value: String,
|
|
#[arg(long)]
|
|
owner: Option<String>,
|
|
/// Allow committing an empty value. Without this, a zero-byte stdin
|
|
/// read is rejected to prevent silent data loss from upstream
|
|
/// pipeline failures.
|
|
#[arg(long, default_value_t = false)]
|
|
allow_empty: bool,
|
|
},
|
|
/// Apply a unified diff to a slug's current value (safer than set).
|
|
///
|
|
/// Reads the diff from stdin or `--patch-file`. Refuses to apply if the
|
|
/// slug has changed since `--base-hash` was captured, and refuses
|
|
/// hunks whose context doesn't match the current value verbatim.
|
|
Patch {
|
|
slug: String,
|
|
/// Read the patch from a file instead of stdin.
|
|
#[arg(long)]
|
|
patch_file: Option<String>,
|
|
/// sha256 hex digest (lowercase) of the value the patch was generated
|
|
/// against. Hashes the exact UTF-8 bytes returned by `buzz mem get`,
|
|
/// not normalized lines. Run `buzz mem hash <slug>` to capture this
|
|
/// before editing.
|
|
#[arg(long)]
|
|
base_hash: Option<String>,
|
|
/// Skip the base-hash check. Unsafe if concurrent edits are possible —
|
|
/// the patch will be applied against whatever the current value is,
|
|
/// even if another agent rewrote it after the patch was generated.
|
|
#[arg(long, default_value_t = false)]
|
|
no_base_hash: bool,
|
|
/// Echo the input patch + resulting sha256 and exit without writing.
|
|
#[arg(long, default_value_t = false)]
|
|
dry_run: bool,
|
|
/// Allow committing an empty result.
|
|
#[arg(long, default_value_t = false)]
|
|
allow_empty: bool,
|
|
#[arg(long)]
|
|
owner: Option<String>,
|
|
},
|
|
/// Publish a tombstone for a slug (cannot be used on `core`).
|
|
Rm {
|
|
slug: String,
|
|
#[arg(long)]
|
|
owner: Option<String>,
|
|
},
|
|
}
|
|
|
|
/// Subcommands for `buzz 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,
|
|
},
|
|
}
|
|
|
|
/// Community moderation commands.
|
|
///
|
|
/// The community (tenant) is selected by the relay host in `--relay` /
|
|
/// `BUZZ_RELAY_URL` — moderation commands are community-global and carry no
|
|
/// channel scope. The signing key must be a community owner/admin; the relay
|
|
/// authorizes every command.
|
|
#[derive(Subcommand)]
|
|
pub enum ModerationCmd {
|
|
/// List reports in the moderation queue (newest first)
|
|
#[command(
|
|
after_help = "Examples:\n buzz moderation reports\n buzz moderation reports --status open --limit 20"
|
|
)]
|
|
Reports {
|
|
/// Filter by status: open | resolved | dismissed | escalated (default: all)
|
|
#[arg(long)]
|
|
status: Option<String>,
|
|
/// Maximum number of reports to return
|
|
#[arg(long, default_value_t = 50)]
|
|
limit: i64,
|
|
},
|
|
/// Resolve or dismiss a report (kind 9044)
|
|
#[command(
|
|
after_help = "Examples:\n buzz moderation resolve --report <REPORT_EVENT_ID> --status dismissed --action dismiss\n buzz moderation resolve --report <REPORT_EVENT_ID> --status resolved --action ban --reason \"rule 3\""
|
|
)]
|
|
Resolve {
|
|
/// Hex event id of the kind:1984 report being resolved
|
|
#[arg(long)]
|
|
report: String,
|
|
/// Resolution status: resolved | dismissed
|
|
#[arg(long)]
|
|
status: String,
|
|
/// Action taken: delete | kick | ban | timeout | dismiss | escalate
|
|
#[arg(long)]
|
|
action: String,
|
|
/// Optional reason — relayed to the reporter, so keep it tombstone-safe
|
|
#[arg(long)]
|
|
reason: Option<String>,
|
|
},
|
|
/// Ban a member from the community (kind 9040)
|
|
#[command(
|
|
after_help = "Examples:\n buzz moderation ban --pubkey <HEX>\n buzz moderation ban --pubkey <HEX> --expires-in 604800 --reason \"repeated spam\""
|
|
)]
|
|
Ban {
|
|
/// Target member pubkey (hex)
|
|
#[arg(long)]
|
|
pubkey: String,
|
|
/// Ban duration in seconds from now (omit for a permanent ban)
|
|
#[arg(long, conflicts_with = "expires_at")]
|
|
expires_in: Option<u64>,
|
|
/// Absolute ban expiry as a unix timestamp (seconds)
|
|
#[arg(long)]
|
|
expires_at: Option<u64>,
|
|
/// Optional private ban reason (audit only)
|
|
#[arg(long)]
|
|
reason: Option<String>,
|
|
},
|
|
/// Lift a member's ban (kind 9041)
|
|
Unban {
|
|
/// Target member pubkey (hex)
|
|
#[arg(long)]
|
|
pubkey: String,
|
|
},
|
|
/// Time out a member — a write-block, not a disconnect (kind 9042)
|
|
#[command(
|
|
after_help = "Examples:\n buzz moderation timeout --pubkey <HEX> --expires-in 3600\n buzz moderation timeout --pubkey <HEX> --expires-at 1783500000 --reason \"cool off\""
|
|
)]
|
|
Timeout {
|
|
/// Target member pubkey (hex)
|
|
#[arg(long)]
|
|
pubkey: String,
|
|
/// Timeout duration in seconds from now
|
|
#[arg(long, conflicts_with = "expires_at")]
|
|
expires_in: Option<u64>,
|
|
/// Absolute timeout expiry as a unix timestamp (seconds)
|
|
#[arg(long)]
|
|
expires_at: Option<u64>,
|
|
/// Optional private timeout reason (audit only)
|
|
#[arg(long)]
|
|
reason: Option<String>,
|
|
},
|
|
/// Clear a member's timeout early (kind 9043)
|
|
Untimeout {
|
|
/// Target member pubkey (hex)
|
|
#[arg(long)]
|
|
pubkey: String,
|
|
},
|
|
/// List currently-restricted members (active ban or timeout)
|
|
Restricted,
|
|
/// Read the moderation audit trail (newest first)
|
|
Audit {
|
|
/// Maximum number of audit rows to return
|
|
#[arg(long, default_value_t = 50)]
|
|
limit: i64,
|
|
},
|
|
}
|
|
|
|
/// Normalize hand-authored `BUZZ_AUTH_TAG` input to strict JSON.
|
|
///
|
|
/// `.env` files and shell exports sometimes carry the tag in the unquoted
|
|
/// shorthand `[auth,<hex>,<conditions>,<hex>]` (quotes dropped by hand).
|
|
/// When the input is not valid JSON but is bracket-delimited, rewrite it as
|
|
/// a JSON array of the comma-separated fields (an empty field `,,` becomes
|
|
/// `""`, matching the canonical form `["auth","hex","","hex"]`).
|
|
///
|
|
/// This is presentation-layer leniency at the configuration edge only: the
|
|
/// output is always fed through the SDK's strict `parse_auth_tag` /
|
|
/// `verify_auth_tag`, which enforce structure, hex, the conditions grammar,
|
|
/// and the BIP-340 signature. Inputs that are already valid JSON — or not
|
|
/// recognizable as the shorthand — are returned unchanged so the strict
|
|
/// parser reports the error on the original bytes.
|
|
fn normalize_auth_tag_input(input: &str) -> String {
|
|
let trimmed = input.trim();
|
|
if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
|
|
return trimmed.to_owned();
|
|
}
|
|
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
|
let fields: Vec<&str> = trimmed[1..trimmed.len() - 1]
|
|
.split(',')
|
|
.map(str::trim)
|
|
.collect();
|
|
// Only a plausible 4-field auth tag is rewritten; anything else is
|
|
// passed through untouched for the strict parser to reject with an
|
|
// error that references the caller's original input.
|
|
if fields.len() == 4 && !fields.iter().any(|f| f.contains('"')) {
|
|
// serde_json cannot fail serializing a Vec<&str>.
|
|
return serde_json::to_string(&fields).expect("string array serializes");
|
|
}
|
|
}
|
|
trimmed.to_owned()
|
|
}
|
|
|
|
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("BUZZ_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 BUZZ_PRIVATE_KEY: {e}")))?;
|
|
|
|
// NIP-OA: parse and verify the auth tag if provided.
|
|
//
|
|
// `BUZZ_AUTH_TAG` is hand-authored configuration, so the unquoted raw
|
|
// shorthand `[auth,hex,,hex]` is normalized to JSON here — at this input
|
|
// edge only. The SDK grammar and the `x-auth-tag` wire format stay strict
|
|
// JSON; all validation and signature verification happen on the strict
|
|
// path below, unchanged.
|
|
let (auth_tag, auth_tag_json) = match cli.auth_tag {
|
|
Some(ref input) if !input.is_empty() => {
|
|
let json = normalize_auth_tag_input(input);
|
|
let tag = buzz_sdk::nip_oa::parse_auth_tag(&json)
|
|
.map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG is malformed: {e}")))?;
|
|
buzz_sdk::nip_oa::verify_auth_tag(&json, &keys.public_key()).map_err(|e| {
|
|
CliError::Auth(format!(
|
|
"BUZZ_AUTH_TAG verification failed for pubkey {}: {e}",
|
|
keys.public_key().to_hex()
|
|
))
|
|
})?;
|
|
// Canonical wire form derives from the parsed-and-verified tag
|
|
// (same shape as buzz-acp's RestClient), never from raw input.
|
|
let canonical = serde_json::to_string(tag.as_slice())
|
|
.map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG serialization failed: {e}")))?;
|
|
(Some(tag), Some(canonical))
|
|
}
|
|
_ => (None, None),
|
|
};
|
|
|
|
let client = BuzzClient::new(relay_url, keys, auth_tag, auth_tag_json)?;
|
|
|
|
match cli.command {
|
|
Cmd::Agents(sub) => commands::agents::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::Emoji(sub) => commands::emoji::dispatch(sub, &client).await,
|
|
Cmd::Dms(sub) => commands::dms::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, &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,
|
|
Cmd::Projects(sub) => commands::projects::dispatch(sub, &client).await,
|
|
Cmd::Patches(sub) => commands::patches::dispatch(sub, &client).await,
|
|
Cmd::Issues(sub) => commands::issues::dispatch(sub, &client).await,
|
|
Cmd::Pr(sub) => commands::pr::dispatch(sub, &client).await,
|
|
Cmd::Media(sub) => commands::upload::dispatch_media(sub, &client).await,
|
|
Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await,
|
|
Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await,
|
|
Cmd::Moderation(sub) => commands::moderation::dispatch(sub, &client, &cli.format).await,
|
|
Cmd::Pack(_) => unreachable!("handled above"),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use clap::CommandFactory;
|
|
|
|
/// Raw shorthand `[auth,hex,,hex]` normalizes to strict JSON; the empty
|
|
/// conditions field becomes `""`.
|
|
#[test]
|
|
fn normalize_auth_tag_raw_shorthand() {
|
|
let owner = "a".repeat(64);
|
|
let sig = "b".repeat(128);
|
|
|
|
let raw = format!("[auth,{owner},,{sig}]");
|
|
let json = normalize_auth_tag_input(&raw);
|
|
let parsed: Vec<String> = serde_json::from_str(&json).expect("output must be JSON");
|
|
assert_eq!(parsed, vec!["auth", &owner, "", &sig]);
|
|
|
|
// With conditions and surrounding whitespace (shell/.env artifacts).
|
|
let raw = format!(" [auth, {owner} , kind=9, {sig}] \n");
|
|
let json = normalize_auth_tag_input(&raw);
|
|
let parsed: Vec<String> = serde_json::from_str(&json).expect("output must be JSON");
|
|
assert_eq!(parsed, vec!["auth", &owner, "kind=9", &sig]);
|
|
}
|
|
|
|
/// Valid JSON input passes through byte-identical (modulo outer trim) —
|
|
/// the normalizer must never rewrite well-formed input.
|
|
#[test]
|
|
fn normalize_auth_tag_json_passthrough() {
|
|
let owner = "a".repeat(64);
|
|
let sig = "b".repeat(128);
|
|
let json_in = serde_json::json!(["auth", owner, "kind=9", sig]).to_string();
|
|
assert_eq!(normalize_auth_tag_input(&json_in), json_in);
|
|
}
|
|
|
|
/// Inputs that are neither JSON nor a plausible 4-field shorthand pass
|
|
/// through unchanged, so the strict parser rejects the original bytes.
|
|
#[test]
|
|
fn normalize_auth_tag_leaves_garbage_untouched() {
|
|
for garbage in [
|
|
"not a tag",
|
|
"[auth,too,few]",
|
|
"[a,b,c,d,e]",
|
|
r#"[auth,"quoted",x,y]"#, // quote chars => not the shorthand
|
|
"[]",
|
|
"{\"auth\":1}",
|
|
] {
|
|
assert_eq!(normalize_auth_tag_input(garbage), garbage.trim());
|
|
}
|
|
}
|
|
|
|
/// Smoke test: CLI definition is valid and parseable.
|
|
#[test]
|
|
fn cli_definition_is_valid() {
|
|
Cli::command().debug_assert();
|
|
}
|
|
|
|
#[test]
|
|
fn set_status_clear_rejects_text_and_emoji() {
|
|
for extra in [["--text", "busy"], ["--emoji", "🎶"]] {
|
|
let args = ["buzz", "users", "set-status", "--clear"]
|
|
.into_iter()
|
|
.chain(extra);
|
|
assert!(
|
|
Cli::try_parse_from(args).is_err(),
|
|
"--clear must conflict with {}",
|
|
extra[0]
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn set_status_requires_text_or_clear() {
|
|
assert!(Cli::try_parse_from(["buzz", "users", "set-status"]).is_err());
|
|
assert!(
|
|
Cli::try_parse_from(["buzz", "users", "set-status", "--emoji", "🎶"]).is_err(),
|
|
"--emoji alone must not imply a status"
|
|
);
|
|
assert!(Cli::try_parse_from(["buzz", "users", "set-status", "--clear"]).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn command_inventory_is_stable() {
|
|
let expected_groups: Vec<&str> = vec![
|
|
"agents",
|
|
"canvas",
|
|
"channels",
|
|
"dms",
|
|
"emoji",
|
|
"feed",
|
|
"issues",
|
|
"media",
|
|
"mem",
|
|
"messages",
|
|
"moderation",
|
|
"notes",
|
|
"pack",
|
|
"patches",
|
|
"pr",
|
|
"projects",
|
|
"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, "agents"),
|
|
vec![
|
|
"archive",
|
|
"archived",
|
|
"draft-create",
|
|
"draft-update",
|
|
"unarchive"
|
|
]
|
|
);
|
|
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",
|
|
"search",
|
|
"set-add-policy",
|
|
"topic",
|
|
"unarchive",
|
|
"update"
|
|
]
|
|
);
|
|
assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]);
|
|
assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]);
|
|
assert_eq!(
|
|
names(&cmd, "emoji"),
|
|
vec!["export", "import", "list", "rm", "set"]
|
|
);
|
|
assert_eq!(
|
|
names(&cmd, "dms"),
|
|
vec!["add-member", "hide", "list", "open"]
|
|
);
|
|
assert_eq!(
|
|
names(&cmd, "users"),
|
|
vec![
|
|
"get",
|
|
"presence",
|
|
"set-presence",
|
|
"set-profile",
|
|
"set-status"
|
|
]
|
|
);
|
|
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",
|
|
"list",
|
|
"notes",
|
|
"publish",
|
|
"set-contacts",
|
|
"set-list"
|
|
]
|
|
);
|
|
assert_eq!(
|
|
names(&cmd, "repos"),
|
|
vec!["bind", "create", "get", "list", "protect"]
|
|
);
|
|
let repos = cmd
|
|
.get_subcommands()
|
|
.find(|subcommand| subcommand.get_name() == "repos")
|
|
.expect("repos command");
|
|
let protect = repos
|
|
.get_subcommands()
|
|
.find(|subcommand| subcommand.get_name() == "protect")
|
|
.expect("repos protect command");
|
|
let mut protect_names: Vec<String> = protect
|
|
.get_subcommands()
|
|
.map(|subcommand| subcommand.get_name().to_string())
|
|
.filter(|name| name != "help")
|
|
.collect();
|
|
protect_names.sort();
|
|
assert_eq!(protect_names, vec!["list", "remove", "set"]);
|
|
assert_eq!(
|
|
names(&cmd, "pr"),
|
|
vec!["get", "list", "open", "status", "update"]
|
|
);
|
|
assert_eq!(
|
|
names(&cmd, "patches"),
|
|
vec!["get", "list", "send", "status"]
|
|
);
|
|
assert_eq!(
|
|
names(&cmd, "projects"),
|
|
vec![
|
|
"add-repo",
|
|
"create",
|
|
"delete",
|
|
"get",
|
|
"list",
|
|
"remove-repo",
|
|
"update"
|
|
]
|
|
);
|
|
assert_eq!(
|
|
names(&cmd, "issues"),
|
|
vec!["create", "get", "list", "status"]
|
|
);
|
|
assert_eq!(names(&cmd, "media"), vec!["get"]);
|
|
assert_eq!(names(&cmd, "upload"), vec!["file"]);
|
|
assert_eq!(names(&cmd, "pack"), vec!["inspect", "validate"]);
|
|
assert_eq!(
|
|
names(&cmd, "moderation"),
|
|
vec![
|
|
"audit",
|
|
"ban",
|
|
"reports",
|
|
"resolve",
|
|
"restricted",
|
|
"timeout",
|
|
"unban",
|
|
"untimeout"
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn subcommand_counts_are_stable() {
|
|
let expected: Vec<(&str, usize)> = vec![
|
|
("agents", 5),
|
|
("canvas", 2),
|
|
("channels", 16),
|
|
("dms", 4),
|
|
("emoji", 5),
|
|
("feed", 1),
|
|
("issues", 4),
|
|
("media", 1),
|
|
("messages", 8),
|
|
("pack", 2),
|
|
("patches", 4),
|
|
("pr", 5),
|
|
("projects", 7),
|
|
("reactions", 3),
|
|
("repos", 5),
|
|
("social", 7),
|
|
("upload", 1),
|
|
("users", 5),
|
|
("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
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Collect all args (recursing into subcommands) whose env var name looks
|
|
/// like a credential but does NOT have `hide_env_values` set.
|
|
fn collect_unhidden_secret_args(cmd: &clap::Command) -> Vec<(String, String)> {
|
|
const SECRET_PATTERNS: &[&str] = &["KEY", "SECRET", "TOKEN", "PASSWORD", "CRED", "AUTH"];
|
|
|
|
let mut violations: Vec<(String, String)> = Vec::new();
|
|
|
|
for arg in cmd.get_arguments() {
|
|
if let Some(env_key) = arg.get_env() {
|
|
let env_name = env_key.to_string_lossy().to_uppercase();
|
|
let is_secret = SECRET_PATTERNS.iter().any(|pat| env_name.contains(pat));
|
|
if is_secret && !arg.is_hide_env_values_set() {
|
|
violations.push((cmd.get_name().to_string(), env_name));
|
|
}
|
|
}
|
|
}
|
|
|
|
for sub in cmd.get_subcommands() {
|
|
violations.extend(collect_unhidden_secret_args(sub));
|
|
}
|
|
|
|
violations
|
|
}
|
|
|
|
/// Every arg whose env var name contains KEY/SECRET/TOKEN/PASSWORD/CRED/AUTH
|
|
/// must set `hide_env_values = true` to prevent credential leakage in --help.
|
|
#[test]
|
|
fn secret_env_args_hide_their_values_in_help() {
|
|
let cmd = Cli::command();
|
|
let violations = collect_unhidden_secret_args(&cmd);
|
|
assert!(
|
|
violations.is_empty(),
|
|
"Found secret-bearing env args without hide_env_values=true. \
|
|
Add `hide_env_values = true` to each:\n{}",
|
|
violations
|
|
.iter()
|
|
.map(|(cmd, env)| format!(" command={cmd:?} env={env:?}"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
);
|
|
}
|
|
|
|
// ── projects update mutation group ────────────────────────────────────────
|
|
|
|
/// Multiple independent fields must be accepted in the same invocation.
|
|
#[test]
|
|
fn projects_update_multi_field_is_accepted() {
|
|
assert!(
|
|
Cli::try_parse_from([
|
|
"buzz",
|
|
"projects",
|
|
"update",
|
|
"my-slug",
|
|
"--name",
|
|
"X",
|
|
"--description",
|
|
"Y",
|
|
])
|
|
.is_ok(),
|
|
"--name and --description together must be accepted"
|
|
);
|
|
}
|
|
|
|
/// A setter for one field and a clearer for a different field must be accepted.
|
|
#[test]
|
|
fn projects_update_setter_with_other_clearer_is_accepted() {
|
|
assert!(
|
|
Cli::try_parse_from([
|
|
"buzz",
|
|
"projects",
|
|
"update",
|
|
"my-slug",
|
|
"--name",
|
|
"X",
|
|
"--clear-description",
|
|
])
|
|
.is_ok(),
|
|
"--name with --clear-description must be accepted"
|
|
);
|
|
}
|
|
|
|
/// A setter and its own clearer are mutually exclusive — clap must reject this.
|
|
#[test]
|
|
fn projects_update_setter_with_own_clearer_is_rejected() {
|
|
assert!(
|
|
Cli::try_parse_from([
|
|
"buzz",
|
|
"projects",
|
|
"update",
|
|
"my-slug",
|
|
"--name",
|
|
"X",
|
|
"--clear-name",
|
|
])
|
|
.is_err(),
|
|
"--name and --clear-name together must be rejected by clap"
|
|
);
|
|
}
|
|
|
|
/// Providing no mutation options at all must be rejected by clap (required group).
|
|
#[test]
|
|
fn projects_update_no_mutation_is_rejected_by_clap() {
|
|
// Without credentials, a valid parse would reach authentication and fail
|
|
// with auth_error — but a clap-level rejection happens before any I/O.
|
|
// We verify it's a clap error (not just any error) by checking the error
|
|
// kind is not a runtime/auth failure — Cli::try_parse_from returns Err
|
|
// immediately for argument violations.
|
|
assert!(
|
|
Cli::try_parse_from(["buzz", "projects", "update", "my-slug"]).is_err(),
|
|
"update with no setters or clearers must be rejected at parse time"
|
|
);
|
|
}
|
|
|
|
/// An unrecognised visibility token must be rejected by clap before any I/O.
|
|
#[test]
|
|
fn projects_create_invalid_visibility_is_rejected_by_clap() {
|
|
assert!(
|
|
Cli::try_parse_from([
|
|
"buzz",
|
|
"projects",
|
|
"create",
|
|
"my-slug",
|
|
"--repo",
|
|
"buzz",
|
|
"--visibility",
|
|
"chartreuse",
|
|
])
|
|
.is_err(),
|
|
"--visibility chartreuse must be rejected at parse time"
|
|
);
|
|
}
|
|
|
|
/// An unrecognised visibility token on update must be rejected by clap before any I/O.
|
|
#[test]
|
|
fn projects_update_invalid_visibility_is_rejected_by_clap() {
|
|
assert!(
|
|
Cli::try_parse_from([
|
|
"buzz",
|
|
"projects",
|
|
"update",
|
|
"my-slug",
|
|
"--visibility",
|
|
"chartreuse",
|
|
])
|
|
.is_err(),
|
|
"--visibility chartreuse on update must be rejected at parse time"
|
|
);
|
|
}
|
|
}
|