Files
buzz/crates/sprout-cli/src/lib.rs
2026-05-11 20:41:02 -04:00

1026 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

mod client;
mod commands;
mod error;
mod validate;
use clap::{Parser, Subcommand};
use client::SproutClient;
use error::CliError;
use nostr::Keys;
/// Run the Sprout CLI from raw arguments (including `argv[0]`).
///
/// Returns a process exit code (0 = success).
///
/// # Example
///
/// ```ignore
/// let code = sprout_cli::run_from_args(std::env::args()).await;
/// std::process::exit(code);
/// ```
pub async fn run_from_args<I, S>(args: I) -> i32
where
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString> + Clone,
{
let cli = match Cli::try_parse_from(args) {
Ok(cli) => cli,
Err(e) => {
if e.use_stderr() {
error::print_error(&CliError::Usage(e.to_string()));
return 1;
} else {
// --help and --version: print normally (intentional human output)
let _ = e.print();
return 0;
}
}
};
match run(cli).await {
Ok(()) => 0,
Err(e) => {
error::print_error(&e);
error::exit_code(&e)
}
}
}
// ---------------------------------------------------------------------------
// Top-level CLI
// ---------------------------------------------------------------------------
#[derive(Parser)]
#[command(name = "sprout", about = "Sprout CLI — interact with a Sprout relay")]
struct Cli {
#[arg(
long,
env = "SPROUT_RELAY_URL",
default_value = "http://localhost:3000"
)]
relay: String,
/// Nostr private key (hex or nsec). This is the CLI's identity.
#[arg(long, env = "SPROUT_PRIVATE_KEY")]
private_key: Option<String>,
/// NIP-OA auth tag JSON (owner attestation). Injected into every signed event.
#[arg(long, env = "SPROUT_AUTH_TAG")]
auth_tag: Option<String>,
#[command(subcommand)]
command: Cmd,
}
// ---------------------------------------------------------------------------
// Subcommands
// ---------------------------------------------------------------------------
#[derive(Subcommand)]
enum Cmd {
// ---- Messages ----------------------------------------------------------
/// Send a message to a channel
SendMessage {
#[arg(long)]
channel: String,
#[arg(long)]
content: String,
#[arg(long)]
kind: Option<u16>,
#[arg(long)]
reply_to: Option<String>,
#[arg(long, default_value_t = false)]
broadcast: bool,
#[arg(long = "mention")]
mentions: Vec<String>,
/// Attach file(s) — uploads and includes as imeta tags
#[arg(long = "file")]
files: Vec<String>,
},
/// Send a diff/code-review message
SendDiffMessage {
#[arg(long)]
channel: String,
#[arg(long)]
diff: String,
#[arg(long)]
repo: String,
#[arg(long)]
commit: String,
#[arg(long)]
file: Option<String>,
#[arg(long)]
parent_commit: Option<String>,
#[arg(long)]
source_branch: Option<String>,
#[arg(long)]
target_branch: Option<String>,
#[arg(long)]
pr: Option<u32>,
#[arg(long)]
lang: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long)]
reply_to: Option<String>,
},
/// Delete a message by event ID
DeleteMessage {
#[arg(long)]
event: String,
},
/// Get messages from a channel
GetMessages {
#[arg(long)]
channel: String,
#[arg(long)]
limit: Option<u32>,
#[arg(long)]
before: Option<i64>,
#[arg(long)]
since: Option<i64>,
#[arg(long)]
kinds: Option<String>,
},
/// Get a message thread
GetThread {
#[arg(long)]
channel: String,
#[arg(long)]
event: String,
#[arg(long)]
depth_limit: Option<u32>,
#[arg(long)]
limit: Option<u32>,
},
/// Search messages
Search {
#[arg(long)]
query: String,
#[arg(long)]
limit: Option<u32>,
},
/// Edit a message you previously sent
EditMessage {
/// Event ID of the message to edit (64-char hex)
#[arg(long)]
event: String,
/// New message content
#[arg(long)]
content: String,
},
/// Vote on a forum post or comment (up or down)
VoteOnPost {
/// Event ID of the post to vote on (64-char hex)
#[arg(long)]
event: String,
/// Vote direction: "up" or "down"
#[arg(long)]
direction: String,
},
/// Upload a file to the relay (returns BlobDescriptor JSON)
UploadFile {
/// Path to the file to upload
#[arg(long)]
file: String,
},
// ---- Channels ----------------------------------------------------------
/// List channels
ListChannels {
#[arg(long)]
visibility: Option<String>,
#[arg(long, default_value_t = false)]
member: bool,
},
/// Get a channel by ID
GetChannel {
#[arg(long)]
channel: String,
},
/// Create a new channel
CreateChannel {
#[arg(long)]
name: String,
#[arg(long = "type")]
channel_type: String,
#[arg(long)]
visibility: String,
#[arg(long)]
description: Option<String>,
},
/// Update a channel's name or description
UpdateChannel {
#[arg(long)]
channel: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
},
/// Set a channel's topic
SetChannelTopic {
#[arg(long)]
channel: String,
#[arg(long)]
topic: String,
},
/// Set a channel's purpose
SetChannelPurpose {
#[arg(long)]
channel: String,
#[arg(long)]
purpose: String,
},
/// Join a channel
JoinChannel {
#[arg(long)]
channel: String,
},
/// Leave a channel
LeaveChannel {
#[arg(long)]
channel: String,
},
/// Archive a channel
ArchiveChannel {
#[arg(long)]
channel: String,
},
/// Unarchive a channel
UnarchiveChannel {
#[arg(long)]
channel: String,
},
/// Delete a channel
DeleteChannel {
#[arg(long)]
channel: String,
},
/// List channel members
ListChannelMembers {
#[arg(long)]
channel: String,
},
/// Add a member to a channel
AddChannelMember {
#[arg(long)]
channel: String,
#[arg(long)]
pubkey: String,
#[arg(long)]
role: Option<String>,
},
/// Remove a member from a channel
RemoveChannelMember {
#[arg(long)]
channel: String,
#[arg(long)]
pubkey: String,
},
/// Get a channel's canvas
GetCanvas {
#[arg(long)]
channel: String,
},
/// Set a channel's canvas content
SetCanvas {
#[arg(long)]
channel: String,
#[arg(long)]
content: String,
},
// ---- Reactions ---------------------------------------------------------
/// Add a reaction to a message
AddReaction {
#[arg(long)]
event: String,
#[arg(long)]
emoji: String,
},
/// Remove a reaction from a message
RemoveReaction {
#[arg(long)]
event: String,
#[arg(long)]
emoji: String,
},
/// Get reactions on a message
GetReactions {
#[arg(long)]
event: String,
},
// ---- DMs ---------------------------------------------------------------
/// List DM conversations
ListDms {
#[arg(long)]
limit: Option<u32>,
},
/// Open a DM with one or more users (1–8 pubkeys)
OpenDm {
#[arg(long = "pubkey")]
pubkeys: Vec<String>,
},
/// Add a member to a DM group
AddDmMember {
#[arg(long)]
channel: String,
#[arg(long)]
pubkey: String,
},
// ---- Users -------------------------------------------------------------
/// Get user profiles by pubkey or display name
GetUsers {
#[arg(long = "pubkey")]
pubkeys: Vec<String>,
/// Search by display name (case-insensitive substring match)
#[arg(long = "name")]
name: Option<String>,
},
/// Update your profile
SetProfile {
#[arg(long)]
name: Option<String>,
#[arg(long)]
avatar: Option<String>,
#[arg(long)]
about: Option<String>,
#[arg(long)]
nip05: Option<String>,
},
/// Get presence status for users (comma-separated pubkeys)
GetPresence {
#[arg(long)]
pubkeys: String,
},
/// Set your presence status
SetPresence {
#[arg(long)]
status: String,
},
// ---- Workflows ---------------------------------------------------------
/// List workflows in a channel
ListWorkflows {
#[arg(long)]
channel: String,
},
/// Create a workflow in a channel
CreateWorkflow {
#[arg(long)]
channel: String,
#[arg(long)]
yaml: String,
},
/// Update a workflow
UpdateWorkflow {
#[arg(long)]
workflow: String,
#[arg(long)]
yaml: String,
},
/// Delete a workflow
DeleteWorkflow {
#[arg(long)]
workflow: String,
},
/// Trigger a workflow manually
TriggerWorkflow {
#[arg(long)]
workflow: String,
},
/// Get workflow run history
GetWorkflowRuns {
#[arg(long)]
workflow: String,
#[arg(long)]
limit: Option<u32>,
},
/// Get a workflow definition
GetWorkflow {
#[arg(long)]
workflow: String,
},
/// Approve or deny a workflow approval step
ApproveStep {
/// The approval token UUID (from the approval request)
#[arg(long)]
token: String,
/// Whether to approve: "true" or "false"
#[arg(long)]
approved: String,
#[arg(long)]
note: Option<String>,
},
// ---- Feed --------------------------------------------------------------
/// Get your activity feed
GetFeed {
#[arg(long)]
since: Option<i64>,
#[arg(long)]
limit: Option<u32>,
#[arg(long)]
types: Option<String>,
},
// Social
/// Publish a short text note (kind:1) to the global feed.
#[command(name = "publish-note")]
PublishNote {
/// Text content of the note.
#[arg(long)]
content: String,
/// 64-char hex event ID to reply to.
#[arg(long)]
reply_to: Option<String>,
},
/// Set the authenticated user's contact/follow list (kind:3). Replaces the entire list.
#[command(name = "set-contact-list")]
SetContactList {
/// JSON array of contacts: [{"pubkey":"hex","relay_url":"...","petname":"..."}]
#[arg(long)]
contacts: String,
},
/// Get a single event by event ID (notes, profiles, contacts, articles, channel events).
#[command(name = "get-event")]
GetEvent {
/// 64-char hex event ID.
#[arg(long)]
event: String,
},
/// List kind:1 text notes by a specific user.
#[command(name = "get-user-notes")]
GetUserNotes {
/// 64-char hex pubkey of the author.
#[arg(long)]
pubkey: String,
/// Maximum number of notes to return (default 50, max 100).
#[arg(long)]
limit: Option<u32>,
/// Unix timestamp cursor — return notes created before this time.
#[arg(long)]
before: Option<i64>,
},
/// Get a user's contact/follow list (kind:3) by hex pubkey.
#[command(name = "get-contact-list")]
GetContactList {
/// 64-char hex pubkey.
#[arg(long)]
pubkey: String,
},
// ---- Git Repos ---------------------------------------------------------
/// Create a git repository (publishes kind:30617 announcement)
#[command(name = "create-repo")]
CreateRepo {
/// 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 relay(s) — can be specified multiple times
#[arg(long = "relay")]
relays: Vec<String>,
},
/// Get a repository's announcement event by ID
#[command(name = "get-repo")]
GetRepo {
/// Repository identifier (d-tag)
#[arg(long)]
id: String,
/// Owner pubkey (64-char hex). Omit to match any owner.
#[arg(long)]
owner: Option<String>,
},
/// List repositories (defaults to your own)
#[command(name = "list-repos")]
ListRepos {
/// Owner pubkey (64-char hex). Omit for your repos.
#[arg(long)]
owner: Option<String>,
/// Maximum number of results
#[arg(long)]
limit: Option<u32>,
},
// ---- Pack (local) ------------------------------------------------------
/// Persona pack operations (local, no relay connection needed)
#[command(subcommand)]
Pack(PackCmd),
}
/// Subcommands for `sprout pack`.
#[derive(Subcommand)]
enum PackCmd {
/// Validate a persona pack directory
Validate {
/// Path to the pack directory
path: String,
},
/// Inspect a persona pack — show metadata and effective config
Inspect {
/// Path to the pack directory
path: String,
},
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Parse a string flag that must be "true" or "false".
fn parse_bool_flag(flag_name: &str, value: &str) -> Result<bool, CliError> {
match value {
"true" => Ok(true),
"false" => Ok(false),
other => Err(CliError::Usage(format!(
"{flag_name} must be 'true' or 'false' (got: {other})"
))),
}
}
async fn run(cli: Cli) -> Result<(), CliError> {
let relay_url = client::normalize_relay_url(&cli.relay);
// Pack commands are local-only — no relay connection needed.
if let Cmd::Pack(ref sub) = cli.command {
return match sub {
PackCmd::Validate { path } => commands::pack::cmd_validate(path),
PackCmd::Inspect { path } => commands::pack::cmd_inspect(path),
};
}
// Auth: private key is required for all relay operations.
// The keypair IS the identity — no tokens, no other auth.
let private_key_str = cli.private_key.ok_or_else(|| {
CliError::Auth("SPROUT_PRIVATE_KEY is required (use --private-key or set env var)".into())
})?;
let keys = Keys::parse(&private_key_str)
.map_err(|e| CliError::Key(format!("invalid SPROUT_PRIVATE_KEY: {e}")))?;
// NIP-OA: parse and verify the auth tag if provided.
let (auth_tag, auth_tag_json) = match cli.auth_tag {
Some(ref json) if !json.is_empty() => {
let tag = sprout_sdk::nip_oa::parse_auth_tag(json)
.map_err(|e| CliError::Auth(format!("SPROUT_AUTH_TAG is malformed: {e}")))?;
sprout_sdk::nip_oa::verify_auth_tag(json, &keys.public_key()).map_err(|e| {
CliError::Auth(format!(
"SPROUT_AUTH_TAG verification failed for pubkey {}: {e}",
keys.public_key().to_hex()
))
})?;
(Some(tag), Some(json.clone()))
}
_ => (None, None),
};
let client = SproutClient::new(relay_url, keys, auth_tag, auth_tag_json)?;
match cli.command {
// ---- Messages ------------------------------------------------------
Cmd::SendMessage {
channel,
content,
kind,
reply_to,
broadcast,
mentions,
files,
} => {
commands::messages::cmd_send_message(
&client,
commands::messages::SendMessageParams {
channel_id: channel,
content,
kind,
reply_to,
broadcast,
mentions,
files,
},
)
.await
}
Cmd::SendDiffMessage {
channel,
diff,
repo,
commit,
file,
parent_commit,
source_branch,
target_branch,
pr,
lang,
description,
reply_to,
} => {
commands::messages::cmd_send_diff_message(
&client,
commands::messages::SendDiffParams {
channel_id: channel,
diff,
repo_url: repo,
commit_sha: commit,
file_path: file,
parent_commit_sha: parent_commit,
source_branch,
target_branch,
pr_number: pr,
language: lang,
description,
reply_to,
},
)
.await
}
Cmd::DeleteMessage { event } => {
commands::messages::cmd_delete_message(&client, &event).await
}
Cmd::GetMessages {
channel,
limit,
before,
since,
kinds,
} => {
commands::messages::cmd_get_messages(
&client,
&channel,
limit,
before,
since,
kinds.as_deref(),
)
.await
}
Cmd::GetThread {
channel,
event,
depth_limit,
limit,
} => {
commands::messages::cmd_get_thread(&client, &channel, &event, depth_limit, limit).await
}
Cmd::Search { query, limit } => {
commands::messages::cmd_search(&client, &query, limit).await
}
Cmd::EditMessage { event, content } => {
commands::messages::cmd_edit_message(&client, &event, &content).await
}
Cmd::VoteOnPost { event, direction } => {
commands::messages::cmd_vote_on_post(&client, &event, &direction).await
}
Cmd::UploadFile { file } => {
let desc = client.upload_file(&file).await?;
println!(
"{}",
serde_json::to_string_pretty(&desc).map_err(|e| CliError::Other(e.to_string()))?
);
Ok(())
}
// ---- Channels ------------------------------------------------------
Cmd::ListChannels { visibility, member } => {
commands::channels::cmd_list_channels(&client, visibility.as_deref(), Some(member))
.await
}
Cmd::GetChannel { channel } => commands::channels::cmd_get_channel(&client, &channel).await,
Cmd::CreateChannel {
name,
channel_type,
visibility,
description,
} => {
commands::channels::cmd_create_channel(
&client,
&name,
&channel_type,
&visibility,
description.as_deref(),
)
.await
}
Cmd::UpdateChannel {
channel,
name,
description,
} => {
commands::channels::cmd_update_channel(
&client,
&channel,
name.as_deref(),
description.as_deref(),
)
.await
}
Cmd::SetChannelTopic { channel, topic } => {
commands::channels::cmd_set_channel_topic(&client, &channel, &topic).await
}
Cmd::SetChannelPurpose { channel, purpose } => {
commands::channels::cmd_set_channel_purpose(&client, &channel, &purpose).await
}
Cmd::JoinChannel { channel } => {
commands::channels::cmd_join_channel(&client, &channel).await
}
Cmd::LeaveChannel { channel } => {
commands::channels::cmd_leave_channel(&client, &channel).await
}
Cmd::ArchiveChannel { channel } => {
commands::channels::cmd_archive_channel(&client, &channel).await
}
Cmd::UnarchiveChannel { channel } => {
commands::channels::cmd_unarchive_channel(&client, &channel).await
}
Cmd::DeleteChannel { channel } => {
commands::channels::cmd_delete_channel(&client, &channel).await
}
Cmd::ListChannelMembers { channel } => {
commands::channels::cmd_list_channel_members(&client, &channel).await
}
Cmd::AddChannelMember {
channel,
pubkey,
role,
} => {
commands::channels::cmd_add_channel_member(&client, &channel, &pubkey, role.as_deref())
.await
}
Cmd::RemoveChannelMember { channel, pubkey } => {
commands::channels::cmd_remove_channel_member(&client, &channel, &pubkey).await
}
Cmd::GetCanvas { channel } => commands::channels::cmd_get_canvas(&client, &channel).await,
Cmd::SetCanvas { channel, content } => {
commands::channels::cmd_set_canvas(&client, &channel, &content).await
}
// ---- Reactions -----------------------------------------------------
Cmd::AddReaction { event, emoji } => {
commands::reactions::cmd_add_reaction(&client, &event, &emoji).await
}
Cmd::RemoveReaction { event, emoji } => {
commands::reactions::cmd_remove_reaction(&client, &event, &emoji).await
}
Cmd::GetReactions { event } => {
commands::reactions::cmd_get_reactions(&client, &event).await
}
// ---- DMs -----------------------------------------------------------
Cmd::ListDms { limit } => commands::dms::cmd_list_dms(&client, limit).await,
Cmd::OpenDm { pubkeys } => commands::dms::cmd_open_dm(&client, &pubkeys).await,
Cmd::AddDmMember { channel, pubkey } => {
commands::dms::cmd_add_dm_member(&client, &channel, &pubkey).await
}
// ---- Users ---------------------------------------------------------
Cmd::GetUsers { pubkeys, name } => {
commands::users::cmd_get_users(&client, &pubkeys, name.as_deref()).await
}
Cmd::SetProfile {
name,
avatar,
about,
nip05,
} => {
commands::users::cmd_set_profile(
&client,
name.as_deref(),
avatar.as_deref(),
about.as_deref(),
nip05.as_deref(),
)
.await
}
Cmd::GetPresence { pubkeys } => commands::users::cmd_get_presence(&client, &pubkeys).await,
Cmd::SetPresence { status } => commands::users::cmd_set_presence(&client, &status).await,
// ---- Workflows -----------------------------------------------------
Cmd::ListWorkflows { channel } => {
commands::workflows::cmd_list_workflows(&client, &channel).await
}
Cmd::CreateWorkflow { channel, yaml } => {
commands::workflows::cmd_create_workflow(&client, &channel, &yaml).await
}
Cmd::UpdateWorkflow { workflow, yaml } => {
commands::workflows::cmd_update_workflow(&client, &workflow, &yaml).await
}
Cmd::DeleteWorkflow { workflow } => {
commands::workflows::cmd_delete_workflow(&client, &workflow).await
}
Cmd::TriggerWorkflow { workflow } => {
commands::workflows::cmd_trigger_workflow(&client, &workflow).await
}
Cmd::GetWorkflowRuns { workflow, limit } => {
commands::workflows::cmd_get_workflow_runs(&client, &workflow, limit).await
}
Cmd::GetWorkflow { workflow } => {
commands::workflows::cmd_get_workflow(&client, &workflow).await
}
Cmd::ApproveStep {
token,
approved,
note,
} => {
let approved = parse_bool_flag("--approved", &approved)?;
commands::workflows::cmd_approve_step(&client, &token, approved, note.as_deref()).await
}
// ---- Feed ----------------------------------------------------------
Cmd::GetFeed {
since,
limit,
types,
} => commands::feed::cmd_get_feed(&client, since, limit, types.as_deref()).await,
// ---- Social --------------------------------------------------------
Cmd::PublishNote { content, reply_to } => {
commands::social::cmd_publish_note(&client, &content, reply_to.as_deref()).await
}
Cmd::SetContactList { contacts } => {
commands::social::cmd_set_contact_list(&client, &contacts).await
}
Cmd::GetEvent { event } => commands::social::cmd_get_event(&client, &event).await,
Cmd::GetUserNotes {
pubkey,
limit,
before,
} => commands::social::cmd_get_user_notes(&client, &pubkey, limit, before).await,
Cmd::GetContactList { pubkey } => {
commands::social::cmd_get_contact_list(&client, &pubkey).await
}
// ---- Git Repos -------------------------------------------------
Cmd::CreateRepo {
id,
name,
description,
clone_urls,
web,
relays,
} => {
commands::repos::cmd_create_repo(
&client,
&id,
name.as_deref(),
description.as_deref(),
&clone_urls,
web.as_deref(),
&relays,
)
.await
}
Cmd::GetRepo { id, owner } => {
commands::repos::cmd_get_repo(&client, &id, owner.as_deref()).await
}
Cmd::ListRepos { owner, limit } => {
commands::repos::cmd_list_repos(&client, owner.as_deref(), limit).await
}
// ---- Pack (local) --------------------------------------------------
Cmd::Pack(_) => unreachable!("handled above"),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
/// Smoke test: CLI definition is valid and parseable.
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
/// Regression: parse_bool_flag rejects values other than "true"/"false".
#[test]
fn parse_bool_flag_accepts_true() {
assert!(super::parse_bool_flag("--approved", "true").unwrap());
}
#[test]
fn parse_bool_flag_accepts_false() {
assert!(!super::parse_bool_flag("--approved", "false").unwrap());
}
#[test]
fn parse_bool_flag_rejects_invalid() {
let err = super::parse_bool_flag("--approved", "maybe").unwrap_err();
match err {
super::CliError::Usage(msg) => {
assert!(msg.contains("must be 'true' or 'false'"), "got: {msg}");
assert!(msg.contains("maybe"), "got: {msg}");
}
other => panic!("expected Usage error, got: {other:?}"),
}
}
#[test]
fn parse_bool_flag_rejects_empty() {
assert!(super::parse_bool_flag("--approved", "").is_err());
}
/// Parity: the CLI exposes exactly the expected set of commands.
/// Token commands removed (auth, list-tokens, delete-token, delete-all-tokens).
/// SetChannelAddPolicy removed (relay-side policy now).
/// Cursor removed from get-thread, list-dms. before_id removed from get-user-notes.
#[test]
fn command_inventory_is_stable() {
let expected: Vec<&str> = vec![
"add-channel-member",
"add-dm-member",
"add-reaction",
"approve-step",
"archive-channel",
"create-channel",
"create-repo",
"create-workflow",
"delete-channel",
"delete-message",
"delete-workflow",
"edit-message",
"get-canvas",
"get-channel",
"get-contact-list",
"get-event",
"get-feed",
"get-messages",
"get-presence",
"get-reactions",
"get-repo",
"get-thread",
"get-user-notes",
"get-users",
"get-workflow",
"get-workflow-runs",
"join-channel",
"leave-channel",
"list-channel-members",
"list-channels",
"list-dms",
"list-repos",
"list-workflows",
"open-dm",
"pack",
"publish-note",
"remove-channel-member",
"remove-reaction",
"search",
"send-diff-message",
"send-message",
"set-canvas",
"set-channel-purpose",
"set-channel-topic",
"set-contact-list",
"set-presence",
"set-profile",
"trigger-workflow",
"unarchive-channel",
"update-channel",
"update-workflow",
"upload-file",
"vote-on-post",
];
let cmd = Cli::command();
let mut actual: Vec<String> = cmd
.get_subcommands()
.map(|s| s.get_name().to_string())
.filter(|n| n != "help") // clap auto-adds "help"
.collect();
actual.sort();
assert_eq!(
actual.len(),
expected.len(),
"Expected {} commands, got {}. Actual: {:?}",
expected.len(),
actual.len(),
actual
);
assert_eq!(actual, expected, "Command inventory drift detected");
}
}