mod context;
mod handlers;
mod help;
pub mod introspection;
pub use context::CliContext;
pub use handlers::{
handle_export_command, handle_import_command, run_api_call, run_auth_login, run_install_skill,
};
pub use introspection::{
generate_commands_list, generate_help, generate_schema, CommandDef, CommandsListResponse,
HelpResponse, SchemaResponse,
};
use crate::api::{ApiClient, CommandResponse};
use crate::commands;
use crate::commands::ConversationSelector;
use crate::debug;
use crate::profile::{
create_token_store, default_config_path, load_config, make_token_key, resolve_profile_full,
TokenStore, TokenType,
};
use serde_json::Value;
#[allow(dead_code)]
pub fn resolve_token_for_wrapper(
slack_token_env: Option<String>,
token_store: &dyn TokenStore,
token_key: &str,
fallback_token_key: Option<&str>,
explicit_request: bool,
) -> Result<String, String> {
if let Some(env_token) = slack_token_env {
return Ok(env_token);
}
if let Ok(token) = token_store.get(token_key) {
return Ok(token);
}
if !explicit_request {
if let Some(fallback_key) = fallback_token_key {
if let Ok(token) = token_store.get(fallback_key) {
eprintln!("Warning: Primary token not found, falling back to alternative token");
return Ok(token);
}
}
}
if explicit_request {
Err(
"No token found for explicitly requested token type. Set SLACK_TOKEN environment variable or run 'slack login' to obtain a token.".to_string()
)
} else {
Err(
"No token found. Set SLACK_TOKEN environment variable or run 'slack login' to obtain a token.".to_string()
)
}
}
pub async fn get_api_client_with_token_type(
profile_name: Option<String>,
token_type: Option<TokenType>,
) -> Result<ApiClient, String> {
if let Ok(env_token) = std::env::var("SLACK_TOKEN") {
return Ok(ApiClient::with_token(env_token));
}
let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
let config_path = default_config_path().map_err(|e| e.to_string())?;
let config = load_config(&config_path).map_err(|e| e.to_string())?;
let profile = config
.get(&profile_name)
.ok_or_else(|| format!("Profile '{}' not found", profile_name))?;
let token_store = create_token_store().map_err(|e| e.to_string())?;
let resolved_token_type = token_type.or(profile.default_token_type);
let bot_token_key = make_token_key(&profile.team_id, &profile.user_id);
let user_token_key = format!("{}:{}:user", profile.team_id, profile.user_id);
let token = match resolved_token_type {
Some(TokenType::Bot) => {
token_store
.get(&bot_token_key)
.map_err(|e| format!("Failed to get bot token: {}", e))?
}
Some(TokenType::User) => {
token_store
.get(&user_token_key)
.map_err(|e| format!("Failed to get user token: {}", e))?
}
None => {
match token_store.get(&user_token_key) {
Ok(user_token) => user_token,
Err(_) => {
token_store
.get(&bot_token_key)
.map_err(|e| format!("Failed to get token: {}", e))?
}
}
}
};
Ok(ApiClient::with_token(token))
}
#[allow(dead_code)]
pub async fn get_api_client(profile_name: Option<String>) -> Result<ApiClient, String> {
get_api_client_with_token_type(profile_name, None).await
}
pub fn has_flag(args: &[String], flag: &str) -> bool {
args.iter().any(|arg| arg == flag)
}
pub fn should_output_raw(args: &[String]) -> bool {
if has_flag(args, "--raw") {
return true;
}
if let Ok(output_mode) = std::env::var("SLACKRS_OUTPUT") {
return output_mode.trim().to_lowercase() == "raw";
}
false
}
pub fn is_non_interactive_error(error_msg: &str) -> bool {
error_msg.contains("Non-interactive mode error")
|| error_msg.contains("Use --yes flag to confirm in non-interactive mode")
}
#[allow(dead_code)]
pub async fn wrap_with_envelope(
response: Value,
method: &str,
command: &str,
profile_name: Option<String>,
) -> Result<CommandResponse, String> {
wrap_with_envelope_and_token_type(response, method, command, profile_name, None).await
}
pub async fn wrap_with_envelope_and_token_type(
response: Value,
method: &str,
command: &str,
profile_name: Option<String>,
explicit_token_type: Option<TokenType>,
) -> Result<CommandResponse, String> {
let profile_name_str = profile_name.unwrap_or_else(|| "default".to_string());
let config_path = default_config_path().map_err(|e| e.to_string())?;
let profile = resolve_profile_full(&config_path, &profile_name_str)
.map_err(|e| format!("Failed to resolve profile '{}': {}", profile_name_str, e))?;
let token_type_str = if let Some(explicit) = explicit_token_type {
Some(explicit.to_string())
} else if std::env::var("SLACK_TOKEN").is_ok() {
Some(
profile
.default_token_type
.map(|t| t.to_string())
.unwrap_or_else(|| "bot".to_string()),
)
} else {
let token_store = create_token_store().map_err(|e| e.to_string())?;
let bot_token_key = make_token_key(&profile.team_id, &profile.user_id);
let user_token_key = format!("{}:{}:user", profile.team_id, profile.user_id);
let resolved_type = profile.default_token_type.or_else(|| {
if token_store.get(&user_token_key).is_ok() {
Some(TokenType::User)
} else if token_store.get(&bot_token_key).is_ok() {
Some(TokenType::Bot)
} else {
None
}
});
resolved_type.map(|t| t.to_string())
};
Ok(CommandResponse::with_token_type(
response,
Some(profile_name_str),
profile.team_id,
profile.user_id,
method.to_string(),
command.to_string(),
token_type_str,
))
}
pub fn resolve_profile_name(args: &[String]) -> String {
if let Some(profile) = get_option(args, "--profile=") {
return profile;
}
if let Ok(profile) = std::env::var("SLACK_PROFILE") {
return profile;
}
"default".to_string()
}
pub fn get_option(args: &[String], prefix: &str) -> Option<String> {
if let Some(value) = args
.iter()
.find(|arg| arg.starts_with(prefix))
.and_then(|arg| arg.strip_prefix(prefix))
.map(|s| s.to_string())
{
return Some(value);
}
let flag = prefix.strip_suffix('=').unwrap_or(prefix);
if let Some(pos) = args.iter().position(|arg| arg == flag) {
if let Some(value) = args.get(pos + 1) {
if !value.starts_with('-') {
return Some(value.clone());
}
}
}
None
}
pub fn parse_token_type(args: &[String]) -> Result<Option<TokenType>, String> {
if let Some(token_type_str) = get_option(args, "--token-type=") {
return token_type_str
.parse::<TokenType>()
.map(Some)
.map_err(|e| e.to_string());
}
if let Some(pos) = args.iter().position(|arg| arg == "--token-type") {
if let Some(value) = args.get(pos + 1) {
return value
.parse::<TokenType>()
.map(Some)
.map_err(|e| e.to_string());
} else {
return Err("--token-type requires a value (bot or user)".to_string());
}
}
Ok(None)
}
pub async fn run_search(args: &[String]) -> Result<(), String> {
let query = args[2].clone();
let count = get_option(args, "--count=").and_then(|s| s.parse().ok());
let page = get_option(args, "--page=").and_then(|s| s.parse().ok());
let sort = get_option(args, "--sort=");
let sort_dir = get_option(args, "--sort_dir=");
let profile_name = resolve_profile_name(args);
let token_type = parse_token_type(args)?;
let raw = should_output_raw(args);
let client = get_api_client_with_token_type(Some(profile_name.clone()), token_type).await?;
let response = commands::search(&client, query, count, page, sort, sort_dir)
.await
.map_err(|e| e.to_string())?;
crate::api::display_wrapper_error_guidance(&response);
let output = if raw {
serde_json::to_string_pretty(&response).unwrap()
} else {
let response_value = serde_json::to_value(&response).map_err(|e| e.to_string())?;
let wrapped = wrap_with_envelope_and_token_type(
response_value,
"search.messages",
"search",
Some(profile_name),
token_type,
)
.await?;
serde_json::to_string_pretty(&wrapped).unwrap()
};
println!("{}", output);
Ok(())
}
pub fn get_all_options(args: &[String], prefix: &str) -> Vec<String> {
let mut results = Vec::new();
results.extend(
args.iter()
.filter(|arg| arg.starts_with(prefix))
.filter_map(|arg| arg.strip_prefix(prefix))
.map(|s| s.to_string()),
);
let flag = prefix.strip_suffix('=').unwrap_or(prefix);
let mut i = 0;
while i < args.len() {
if args[i] == flag {
if let Some(value) = args.get(i + 1) {
if !value.starts_with('-') {
results.push(value.clone());
i += 2; continue;
}
}
}
i += 1;
}
results
}
pub async fn run_conv_list(args: &[String]) -> Result<(), String> {
if has_flag(args, "--help") || has_flag(args, "-h") {
print_conv_usage(&args[0]);
return Ok(());
}
let types = get_option(args, "--types=");
let include_private = has_flag(args, "--include-private");
let all = has_flag(args, "--all");
let limit = get_option(args, "--limit=").and_then(|s| s.parse().ok());
let profile_name = resolve_profile_name(args);
let token_type = parse_token_type(args)?;
let filter_strings = get_all_options(args, "--filter=");
let raw = should_output_raw(args);
if types.is_some() && (include_private || all) {
return Err("Error: --types cannot be used with --include-private or --all".to_string());
}
let resolved_types = if let Some(explicit_types) = types {
Some(explicit_types)
} else if all {
Some("public_channel,private_channel,im,mpim".to_string())
} else if include_private {
Some("public_channel,private_channel".to_string())
} else {
Some("public_channel,private_channel".to_string())
};
let format = if let Some(fmt_str) = get_option(args, "--format=") {
commands::OutputFormat::parse(&fmt_str)?
} else {
commands::OutputFormat::Json
};
if raw && format != commands::OutputFormat::Json {
return Err(format!(
"--raw is only valid with --format json, but got --format {}",
format
));
}
let sort_key = if let Some(sort_str) = get_option(args, "--sort=") {
Some(commands::SortKey::parse(&sort_str)?)
} else {
None
};
let sort_dir = if let Some(dir_str) = get_option(args, "--sort-dir=") {
commands::SortDirection::parse(&dir_str)?
} else {
commands::SortDirection::default()
};
let filters: Result<Vec<_>, _> = filter_strings
.iter()
.map(|s| commands::ConversationFilter::parse(s))
.collect();
let filters = filters.map_err(|e| e.to_string())?;
let debug_level = debug::get_debug_level(args);
let token_store_backend = if std::env::var("SLACK_TOKEN").is_ok() {
"environment"
} else {
"file"
};
let resolved_token_type = if let Some(explicit) = token_type {
explicit
} else {
let config_path = default_config_path().map_err(|e| e.to_string())?;
let profile = resolve_profile_full(&config_path, &profile_name)
.map_err(|e| format!("Failed to resolve profile '{}': {}", profile_name, e))?;
if let Some(default_type) = profile.default_token_type {
default_type
} else {
let token_store = create_token_store().map_err(|e| e.to_string())?;
let user_token_key = format!("{}:{}:user", profile.team_id, profile.user_id);
if token_store.get(&user_token_key).is_ok() {
TokenType::User
} else {
TokenType::Bot
}
}
};
let endpoint = "https://slack.com/api/conversations.list";
debug::log_api_context(
debug_level,
Some(&profile_name),
token_store_backend,
resolved_token_type.as_str(),
"conversations.list",
endpoint,
);
let client = get_api_client_with_token_type(Some(profile_name.clone()), token_type).await?;
let mut response = commands::conv_list(&client, resolved_types, limit)
.await
.map_err(|e| e.to_string())?;
debug::log_error_code(
debug_level,
&serde_json::to_value(&response).unwrap_or_default(),
);
crate::api::display_wrapper_error_guidance(&response);
commands::apply_filters(&mut response, &filters);
if let Some(key) = sort_key {
commands::sort_conversations(&mut response, key, sort_dir);
}
let output = if format != commands::OutputFormat::Json {
commands::format_response(&response, format)?
} else if raw {
serde_json::to_string_pretty(&response).unwrap()
} else {
let response_value = serde_json::to_value(&response).map_err(|e| e.to_string())?;
let wrapped = wrap_with_envelope_and_token_type(
response_value,
"conversations.list",
"conv list",
Some(profile_name),
token_type,
)
.await?;
serde_json::to_string_pretty(&wrapped).unwrap()
};
println!("{}", output);
Ok(())
}
pub async fn run_conv_select(args: &[String]) -> Result<(), String> {
if has_flag(args, "--help") || has_flag(args, "-h") {
print_conv_usage(&args[0]);
return Ok(());
}
let types = get_option(args, "--types=");
let limit = get_option(args, "--limit=").and_then(|s| s.parse().ok());
let profile_name = resolve_profile_name(args);
let token_type = parse_token_type(args)?;
let filter_strings = get_all_options(args, "--filter=");
let filters: Result<Vec<_>, _> = filter_strings
.iter()
.map(|s| commands::ConversationFilter::parse(s))
.collect();
let filters = filters.map_err(|e| e.to_string())?;
let resolved_types = types.or(Some("public_channel,private_channel".to_string()));
let client = get_api_client_with_token_type(Some(profile_name), token_type).await?;
let mut response = commands::conv_list(&client, resolved_types, limit)
.await
.map_err(|e| e.to_string())?;
commands::apply_filters(&mut response, &filters);
let items = commands::extract_conversations(&response);
let selector = commands::StdinSelector;
let channel_id = selector.select(&items)?;
println!("{}", channel_id);
Ok(())
}
pub async fn run_conv_search(args: &[String]) -> Result<(), String> {
if has_flag(args, "--help") || has_flag(args, "-h") {
print_conv_usage(&args[0]);
return Ok(());
}
let pattern = args
.get(3)
.filter(|arg| !arg.starts_with("--"))
.ok_or_else(|| "Search pattern is required".to_string())?
.clone();
let types = get_option(args, "--types=");
let limit = get_option(args, "--limit=").and_then(|s| s.parse().ok());
let profile_name = resolve_profile_name(args);
let token_type = parse_token_type(args)?;
let raw = should_output_raw(args);
let select = has_flag(args, "--select");
let filter_strings = get_all_options(args, "--filter=");
let format = if let Some(fmt_str) = get_option(args, "--format=") {
commands::OutputFormat::parse(&fmt_str)?
} else {
commands::OutputFormat::Json
};
if raw && format != commands::OutputFormat::Json {
return Err(format!(
"--raw is only valid with --format json, but got --format {}",
format
));
}
let sort_key = if let Some(sort_str) = get_option(args, "--sort=") {
Some(commands::SortKey::parse(&sort_str)?)
} else {
None
};
let sort_dir = if let Some(dir_str) = get_option(args, "--sort-dir=") {
commands::SortDirection::parse(&dir_str)?
} else {
commands::SortDirection::default()
};
let mut filters: Vec<commands::ConversationFilter> =
vec![commands::ConversationFilter::Name(pattern)];
for filter_str in filter_strings {
filters.push(commands::ConversationFilter::parse(&filter_str).map_err(|e| e.to_string())?);
}
let resolved_types = types.or(Some("public_channel,private_channel".to_string()));
let client = get_api_client_with_token_type(Some(profile_name.clone()), token_type).await?;
let mut response = commands::conv_list(&client, resolved_types, limit)
.await
.map_err(|e| e.to_string())?;
commands::apply_filters(&mut response, &filters);
if let Some(key) = sort_key {
commands::sort_conversations(&mut response, key, sort_dir);
}
if select {
let items = commands::extract_conversations(&response);
let selector = commands::StdinSelector;
let channel_id = selector.select(&items)?;
println!("{}", channel_id);
return Ok(());
}
let output = if format != commands::OutputFormat::Json {
commands::format_response(&response, format)?
} else if raw {
serde_json::to_string_pretty(&response).unwrap()
} else {
let response_value = serde_json::to_value(&response).map_err(|e| e.to_string())?;
let wrapped = wrap_with_envelope_and_token_type(
response_value,
"conversations.list",
"conv search",
Some(profile_name),
token_type,
)
.await?;
serde_json::to_string_pretty(&wrapped).unwrap()
};
println!("{}", output);
Ok(())
}
pub async fn run_conv_history(args: &[String]) -> Result<(), String> {
if has_flag(args, "--help") || has_flag(args, "-h") {
print_conv_usage(&args[0]);
return Ok(());
}
let interactive = has_flag(args, "--interactive");
let channel = if interactive {
let types = get_option(args, "--types=");
let profile_name_inner = resolve_profile_name(args);
let filter_strings = get_all_options(args, "--filter=");
let filters: Result<Vec<_>, _> = filter_strings
.iter()
.map(|s| commands::ConversationFilter::parse(s))
.collect();
let filters = filters.map_err(|e| e.to_string())?;
let resolved_types = types.or(Some("public_channel,private_channel".to_string()));
let token_type_inner = parse_token_type(args)?;
let client =
get_api_client_with_token_type(Some(profile_name_inner), token_type_inner).await?;
let mut response = commands::conv_list(&client, resolved_types, None)
.await
.map_err(|e| e.to_string())?;
commands::apply_filters(&mut response, &filters);
let items = commands::extract_conversations(&response);
let selector = commands::StdinSelector;
selector.select(&items)?
} else {
if args.len() < 4 {
return Err("Channel argument required when --interactive is not used".to_string());
}
args[3].clone()
};
let limit = get_option(args, "--limit=").and_then(|s| s.parse().ok());
let oldest = get_option(args, "--oldest=");
let latest = get_option(args, "--latest=");
let profile_name = resolve_profile_name(args);
let token_type = parse_token_type(args)?;
let raw = should_output_raw(args);
let debug_level = debug::get_debug_level(args);
let token_store_backend = if std::env::var("SLACK_TOKEN").is_ok() {
"environment"
} else {
"file"
};
let resolved_token_type = if let Some(explicit) = token_type {
explicit
} else {
let config_path = default_config_path().map_err(|e| e.to_string())?;
let profile = resolve_profile_full(&config_path, &profile_name)
.map_err(|e| format!("Failed to resolve profile '{}': {}", profile_name, e))?;
if let Some(default_type) = profile.default_token_type {
default_type
} else {
let token_store = create_token_store().map_err(|e| e.to_string())?;
let user_token_key = format!("{}:{}:user", profile.team_id, profile.user_id);
if token_store.get(&user_token_key).is_ok() {
TokenType::User
} else {
TokenType::Bot
}
}
};
let endpoint = "https://slack.com/api/conversations.history";
debug::log_api_context(
debug_level,
Some(&profile_name),
token_store_backend,
resolved_token_type.as_str(),
"conversations.history",
endpoint,
);
let client = get_api_client_with_token_type(Some(profile_name.clone()), token_type).await?;
let response = commands::conv_history(&client, channel, limit, oldest, latest)
.await
.map_err(|e| e.to_string())?;
debug::log_error_code(
debug_level,
&serde_json::to_value(&response).unwrap_or_default(),
);
crate::api::display_wrapper_error_guidance(&response);
let output = if raw {
serde_json::to_string_pretty(&response).unwrap()
} else {
let response_value = serde_json::to_value(&response).map_err(|e| e.to_string())?;
let wrapped = wrap_with_envelope_and_token_type(
response_value,
"conversations.history",
"conv history",
Some(profile_name),
token_type,
)
.await?;
serde_json::to_string_pretty(&wrapped).unwrap()
};
println!("{}", output);
Ok(())
}
pub async fn run_thread_get(args: &[String]) -> Result<(), String> {
if has_flag(args, "--help") || has_flag(args, "-h") {
print_thread_usage(&args[0]);
return Ok(());
}
if args.len() < 5 {
return Err("Usage: slack-rs thread get <channel> <thread_ts> [--limit=N] [--inclusive] [--raw] [--profile=NAME] [--token-type=bot|user]".to_string());
}
let channel = args[3].clone();
let thread_ts = args[4].clone();
let limit = get_option(args, "--limit=").and_then(|s| s.parse().ok());
let inclusive = has_flag(args, "--inclusive");
let profile_name = resolve_profile_name(args);
let token_type = parse_token_type(args)?;
let raw = should_output_raw(args);
let debug_level = debug::get_debug_level(args);
let token_store_backend = if std::env::var("SLACK_TOKEN").is_ok() {
"environment"
} else {
"file"
};
let resolved_token_type = if let Some(explicit) = token_type {
explicit
} else {
let config_path = default_config_path().map_err(|e| e.to_string())?;
let profile = resolve_profile_full(&config_path, &profile_name)
.map_err(|e| format!("Failed to resolve profile '{}': {}", profile_name, e))?;
if let Some(default_type) = profile.default_token_type {
default_type
} else {
let token_store = create_token_store().map_err(|e| e.to_string())?;
let user_token_key = format!("{}:{}:user", profile.team_id, profile.user_id);
if token_store.get(&user_token_key).is_ok() {
TokenType::User
} else {
TokenType::Bot
}
}
};
let endpoint = "https://slack.com/api/conversations.replies";
debug::log_api_context(
debug_level,
Some(&profile_name),
token_store_backend,
resolved_token_type.as_str(),
"conversations.replies",
endpoint,
);
let client = get_api_client_with_token_type(Some(profile_name.clone()), token_type).await?;
let inclusive_opt = if inclusive { Some(true) } else { None };
let response = commands::thread_get(&client, channel, thread_ts, limit, inclusive_opt)
.await
.map_err(|e| e.to_string())?;
debug::log_error_code(
debug_level,
&serde_json::to_value(&response).unwrap_or_default(),
);
crate::api::display_wrapper_error_guidance(&response);
let output = if raw {
serde_json::to_string_pretty(&response).unwrap()
} else {
let response_value = serde_json::to_value(&response).map_err(|e| e.to_string())?;
let wrapped = wrap_with_envelope_and_token_type(
response_value,
"conversations.replies",
"thread get",
Some(profile_name),
token_type,
)
.await?;
serde_json::to_string_pretty(&wrapped).unwrap()
};
println!("{}", output);
Ok(())
}
pub async fn run_users_info(args: &[String]) -> Result<(), String> {
let user = args[3].clone();
let profile_name = resolve_profile_name(args);
let token_type = parse_token_type(args)?;
let raw = should_output_raw(args);
let debug_level = debug::get_debug_level(args);
let token_store_backend = if std::env::var("SLACK_TOKEN").is_ok() {
"environment"
} else {
"file"
};
let resolved_token_type = if let Some(explicit) = token_type {
explicit
} else {
let config_path = default_config_path().map_err(|e| e.to_string())?;
let profile = resolve_profile_full(&config_path, &profile_name)
.map_err(|e| format!("Failed to resolve profile '{}': {}", profile_name, e))?;
if let Some(default_type) = profile.default_token_type {
default_type
} else {
let token_store = create_token_store().map_err(|e| e.to_string())?;
let user_token_key = format!("{}:{}:user", profile.team_id, profile.user_id);
if token_store.get(&user_token_key).is_ok() {
TokenType::User
} else {
TokenType::Bot
}
}
};
let endpoint = "https://slack.com/api/users.info";
debug::log_api_context(
debug_level,
Some(&profile_name),
token_store_backend,
resolved_token_type.as_str(),
"users.info",
endpoint,
);
let client = get_api_client_with_token_type(Some(profile_name.clone()), token_type).await?;
let response = commands::users_info(&client, user)
.await
.map_err(|e| e.to_string())?;
debug::log_error_code(
debug_level,
&serde_json::to_value(&response).unwrap_or_default(),
);
crate::api::display_wrapper_error_guidance(&response);
let output = if raw {
serde_json::to_string_pretty(&response).unwrap()
} else {
let response_value = serde_json::to_value(&response).map_err(|e| e.to_string())?;
let wrapped = wrap_with_envelope_and_token_type(
response_value,
"users.info",
"users info",
Some(profile_name),
token_type,
)
.await?;
serde_json::to_string_pretty(&wrapped).unwrap()
};
println!("{}", output);
Ok(())
}
pub async fn run_users_cache_update(args: &[String]) -> Result<(), String> {
let profile_name = resolve_profile_name(args);
let force = has_flag(args, "--force");
let token_type = parse_token_type(args)?;
let config_path = default_config_path().map_err(|e| e.to_string())?;
let config = load_config(&config_path).map_err(|e| e.to_string())?;
let profile = config
.get(&profile_name)
.ok_or_else(|| format!("Profile '{}' not found", profile_name))?;
let client = get_api_client_with_token_type(Some(profile_name.clone()), token_type).await?;
commands::update_cache(&client, profile.team_id.clone(), force)
.await
.map_err(|e| e.to_string())?;
println!("Cache updated successfully for team {}", profile.team_id);
Ok(())
}
pub async fn run_users_resolve_mentions(args: &[String]) -> Result<(), String> {
if args.len() < 4 {
return Err(
"Usage: users resolve-mentions <text> [--profile=NAME] [--format=FORMAT]".to_string(),
);
}
let text = args[3].clone();
let profile_name = resolve_profile_name(args);
let format_str = get_option(args, "--format=").unwrap_or_else(|| "display_name".to_string());
let format = format_str.parse::<commands::MentionFormat>().map_err(|_| {
format!(
"Invalid format: {}. Use display_name, real_name, or username",
format_str
)
})?;
let config_path = default_config_path().map_err(|e| e.to_string())?;
let config = load_config(&config_path).map_err(|e| e.to_string())?;
let profile = config
.get(&profile_name)
.ok_or_else(|| format!("Profile '{}' not found", profile_name))?;
let cache_path = commands::UsersCacheFile::default_path()?;
let cache_file = commands::UsersCacheFile::load(&cache_path)?;
let workspace_cache = cache_file.get_workspace(&profile.team_id).ok_or_else(|| {
format!(
"No cache found for team {}. Run 'users cache-update' first.",
profile.team_id
)
})?;
let result = commands::resolve_mentions(&text, workspace_cache, format);
println!("{}", result);
Ok(())
}
async fn get_team_and_user_ids_from_profile(
profile_name: &str,
) -> Result<(String, String), String> {
let config_path = default_config_path().map_err(|e| e.to_string())?;
let profile = resolve_profile_full(&config_path, profile_name)
.map_err(|e| format!("Failed to resolve profile '{}': {}", profile_name, e))?;
Ok((profile.team_id, profile.user_id))
}
pub async fn run_msg_post(args: &[String], non_interactive: bool) -> Result<(), String> {
use crate::idempotency::{IdempotencyCheckResult, IdempotencyHandler};
if args.len() < 5 {
return Err("Usage: msg post <channel> <text> [--thread-ts=TS] [--reply-broadcast] [--yes] [--profile=NAME] [--token-type=bot|user] [--idempotency-key=KEY]".to_string());
}
let channel = args[3].clone();
let text = args[4].clone();
let thread_ts = get_option(args, "--thread-ts=");
let reply_broadcast = has_flag(args, "--reply-broadcast");
let yes = has_flag(args, "--yes");
let profile_name = resolve_profile_name(args);
let token_type = parse_token_type(args)?;
let idempotency_key = get_option(args, "--idempotency-key=");
if reply_broadcast && thread_ts.is_none() {
return Err("Error: --reply-broadcast requires --thread-ts".to_string());
}
let raw = should_output_raw(args);
let client = get_api_client_with_token_type(Some(profile_name.clone()), token_type).await?;
let (response_value, idempotency_status) = if let Some(key) = idempotency_key.clone() {
let mut handler = IdempotencyHandler::new().map_err(|e| e.to_string())?;
let mut params = serde_json::Map::new();
params.insert("channel".to_string(), serde_json::json!(channel.clone()));
params.insert("text".to_string(), serde_json::json!(text.clone()));
if let Some(ref ts) = thread_ts {
params.insert("thread_ts".to_string(), serde_json::json!(ts));
if reply_broadcast {
params.insert("reply_broadcast".to_string(), serde_json::json!(true));
}
}
let (team_id, user_id) = get_team_and_user_ids_from_profile(&profile_name).await?;
match handler
.check(
Some(key.clone()),
team_id.clone(),
user_id.clone(),
"chat.postMessage".to_string(),
¶ms,
)
.map_err(|e| e.to_string())?
{
IdempotencyCheckResult::Replay {
response, status, ..
} => {
(response, Some(status))
}
IdempotencyCheckResult::Execute {
key: scoped_key,
fingerprint,
} => {
let response = commands::msg_post(
&client,
channel,
text,
thread_ts,
reply_broadcast,
yes,
non_interactive,
)
.await
.map_err(|e| e.to_string())?;
let response_value = serde_json::to_value(&response).map_err(|e| e.to_string())?;
handler
.store(scoped_key, fingerprint, response_value.clone())
.map_err(|e| e.to_string())?;
(
response_value,
Some(crate::idempotency::IdempotencyStatus::Executed),
)
}
IdempotencyCheckResult::NoKey => unreachable!(),
}
} else {
let response = commands::msg_post(
&client,
channel,
text,
thread_ts,
reply_broadcast,
yes,
non_interactive,
)
.await
.map_err(|e| e.to_string())?;
(
serde_json::to_value(&response).map_err(|e| e.to_string())?,
None,
)
};
if let Ok(api_response) =
serde_json::from_value::<crate::api::ApiResponse>(response_value.clone())
{
crate::api::display_wrapper_error_guidance(&api_response);
}
let output = if raw {
serde_json::to_string_pretty(&response_value).unwrap()
} else {
let mut wrapped = wrap_with_envelope_and_token_type(
response_value,
"chat.postMessage",
"msg post",
Some(profile_name),
token_type,
)
.await?;
if let (Some(key), Some(status)) = (idempotency_key, idempotency_status) {
wrapped = wrapped.with_idempotency(
key,
match status {
crate::idempotency::IdempotencyStatus::Executed => "executed".to_string(),
crate::idempotency::IdempotencyStatus::Replayed => "replayed".to_string(),
},
);
}
serde_json::to_string_pretty(&wrapped).unwrap()
};
println!("{}", output);
Ok(())
}
pub async fn run_msg_update(args: &[String], non_interactive: bool) -> Result<(), String> {
use crate::idempotency::{IdempotencyCheckResult, IdempotencyHandler};
if args.len() < 6 {
return Err("Usage: msg update <channel> <ts> <text> [--yes] [--profile=NAME] [--token-type=bot|user] [--idempotency-key=KEY]".to_string());
}
let channel = args[3].clone();
let ts = args[4].clone();
let text = args[5].clone();
let yes = has_flag(args, "--yes");
let profile_name = resolve_profile_name(args);
let token_type = parse_token_type(args)?;
let idempotency_key = get_option(args, "--idempotency-key=");
let raw = should_output_raw(args);
let client = get_api_client_with_token_type(Some(profile_name.clone()), token_type).await?;
let (response_value, idempotency_status) = if let Some(key) = idempotency_key.clone() {
let mut handler = IdempotencyHandler::new().map_err(|e| e.to_string())?;
let mut params = serde_json::Map::new();
params.insert("channel".to_string(), serde_json::json!(channel.clone()));
params.insert("ts".to_string(), serde_json::json!(ts.clone()));
params.insert("text".to_string(), serde_json::json!(text.clone()));
let (team_id, user_id) = get_team_and_user_ids_from_profile(&profile_name).await?;
match handler
.check(
Some(key.clone()),
team_id,
user_id,
"chat.update".to_string(),
¶ms,
)
.map_err(|e| e.to_string())?
{
IdempotencyCheckResult::Replay {
response, status, ..
} => (response, Some(status)),
IdempotencyCheckResult::Execute {
key: scoped_key,
fingerprint,
} => {
let response =
commands::msg_update(&client, channel, ts, text, yes, non_interactive)
.await
.map_err(|e| e.to_string())?;
let response_value = serde_json::to_value(&response).map_err(|e| e.to_string())?;
handler
.store(scoped_key, fingerprint, response_value.clone())
.map_err(|e| e.to_string())?;
(
response_value,
Some(crate::idempotency::IdempotencyStatus::Executed),
)
}
IdempotencyCheckResult::NoKey => unreachable!(),
}
} else {
let response = commands::msg_update(&client, channel, ts, text, yes, non_interactive)
.await
.map_err(|e| e.to_string())?;
(
serde_json::to_value(&response).map_err(|e| e.to_string())?,
None,
)
};
if let Ok(api_response) =
serde_json::from_value::<crate::api::ApiResponse>(response_value.clone())
{
crate::api::display_wrapper_error_guidance(&api_response);
}
let output = if raw {
serde_json::to_string_pretty(&response_value).unwrap()
} else {
let mut wrapped = wrap_with_envelope_and_token_type(
response_value,
"chat.update",
"msg update",
Some(profile_name),
token_type,
)
.await?;
if let (Some(key), Some(status)) = (idempotency_key, idempotency_status) {
wrapped = wrapped.with_idempotency(
key,
match status {
crate::idempotency::IdempotencyStatus::Executed => "executed".to_string(),
crate::idempotency::IdempotencyStatus::Replayed => "replayed".to_string(),
},
);
}
serde_json::to_string_pretty(&wrapped).unwrap()
};
println!("{}", output);
Ok(())
}
pub async fn run_msg_delete(args: &[String], non_interactive: bool) -> Result<(), String> {
use crate::idempotency::{IdempotencyCheckResult, IdempotencyHandler};
if args.len() < 5 {
return Err(
"Usage: msg delete <channel> <ts> [--yes] [--profile=NAME] [--token-type=bot|user] [--idempotency-key=KEY]"
.to_string(),
);
}
let channel = args[3].clone();
let ts = args[4].clone();
let yes = has_flag(args, "--yes");
let profile_name = resolve_profile_name(args);
let token_type = parse_token_type(args)?;
let idempotency_key = get_option(args, "--idempotency-key=");
let raw = should_output_raw(args);
let client = get_api_client_with_token_type(Some(profile_name.clone()), token_type).await?;
let (response_value, idempotency_status) = if let Some(key) = idempotency_key.clone() {
let mut handler = IdempotencyHandler::new().map_err(|e| e.to_string())?;
let mut params = serde_json::Map::new();
params.insert("channel".to_string(), serde_json::json!(channel.clone()));
params.insert("ts".to_string(), serde_json::json!(ts.clone()));
let (team_id, user_id) = get_team_and_user_ids_from_profile(&profile_name).await?;
match handler
.check(
Some(key.clone()),
team_id,
user_id,
"chat.delete".to_string(),
¶ms,
)
.map_err(|e| e.to_string())?
{
IdempotencyCheckResult::Replay {
response, status, ..
} => (response, Some(status)),
IdempotencyCheckResult::Execute {
key: scoped_key,
fingerprint,
} => {
let response = commands::msg_delete(&client, channel, ts, yes, non_interactive)
.await
.map_err(|e| e.to_string())?;
let response_value = serde_json::to_value(&response).map_err(|e| e.to_string())?;
handler
.store(scoped_key, fingerprint, response_value.clone())
.map_err(|e| e.to_string())?;
(
response_value,
Some(crate::idempotency::IdempotencyStatus::Executed),
)
}
IdempotencyCheckResult::NoKey => unreachable!(),
}
} else {
let response = commands::msg_delete(&client, channel, ts, yes, non_interactive)
.await
.map_err(|e| e.to_string())?;
(
serde_json::to_value(&response).map_err(|e| e.to_string())?,
None,
)
};
if let Ok(api_response) =
serde_json::from_value::<crate::api::ApiResponse>(response_value.clone())
{
crate::api::display_wrapper_error_guidance(&api_response);
}
let output = if raw {
serde_json::to_string_pretty(&response_value).unwrap()
} else {
let mut wrapped = wrap_with_envelope_and_token_type(
response_value,
"chat.delete",
"msg delete",
Some(profile_name),
token_type,
)
.await?;
if let (Some(key), Some(status)) = (idempotency_key, idempotency_status) {
wrapped = wrapped.with_idempotency(
key,
match status {
crate::idempotency::IdempotencyStatus::Executed => "executed".to_string(),
crate::idempotency::IdempotencyStatus::Replayed => "replayed".to_string(),
},
);
}
serde_json::to_string_pretty(&wrapped).unwrap()
};
println!("{}", output);
Ok(())
}
pub async fn run_react_add(args: &[String], non_interactive: bool) -> Result<(), String> {
use crate::idempotency::{IdempotencyCheckResult, IdempotencyHandler};
if args.len() < 6 {
return Err(
"Usage: react add <channel> <ts> <emoji> [--yes] [--profile=NAME] [--token-type=bot|user] [--idempotency-key=KEY]"
.to_string(),
);
}
let channel = args[3].clone();
let ts = args[4].clone();
let emoji = args[5].clone();
let yes = has_flag(args, "--yes");
let profile_name = resolve_profile_name(args);
let token_type = parse_token_type(args)?;
let idempotency_key = get_option(args, "--idempotency-key=");
let raw = should_output_raw(args);
let client = get_api_client_with_token_type(Some(profile_name.clone()), token_type).await?;
let (response_value, idempotency_status) = if let Some(key) = idempotency_key.clone() {
let mut handler = IdempotencyHandler::new().map_err(|e| e.to_string())?;
let mut params = serde_json::Map::new();
params.insert("channel".to_string(), serde_json::json!(channel.clone()));
params.insert("timestamp".to_string(), serde_json::json!(ts.clone()));
params.insert("name".to_string(), serde_json::json!(emoji.clone()));
let (team_id, user_id) = get_team_and_user_ids_from_profile(&profile_name).await?;
match handler
.check(
Some(key.clone()),
team_id,
user_id,
"reactions.add".to_string(),
¶ms,
)
.map_err(|e| e.to_string())?
{
IdempotencyCheckResult::Replay {
response, status, ..
} => (response, Some(status)),
IdempotencyCheckResult::Execute {
key: scoped_key,
fingerprint,
} => {
let response =
commands::react_add(&client, channel, ts, emoji, yes, non_interactive)
.await
.map_err(|e| e.to_string())?;
let response_value = serde_json::to_value(&response).map_err(|e| e.to_string())?;
handler
.store(scoped_key, fingerprint, response_value.clone())
.map_err(|e| e.to_string())?;
(
response_value,
Some(crate::idempotency::IdempotencyStatus::Executed),
)
}
IdempotencyCheckResult::NoKey => unreachable!(),
}
} else {
let response = commands::react_add(&client, channel, ts, emoji, yes, non_interactive)
.await
.map_err(|e| e.to_string())?;
(
serde_json::to_value(&response).map_err(|e| e.to_string())?,
None,
)
};
if let Ok(api_response) =
serde_json::from_value::<crate::api::ApiResponse>(response_value.clone())
{
crate::api::display_wrapper_error_guidance(&api_response);
}
let output = if raw {
serde_json::to_string_pretty(&response_value).unwrap()
} else {
let mut wrapped = wrap_with_envelope_and_token_type(
response_value,
"reactions.add",
"react add",
Some(profile_name),
token_type,
)
.await?;
if let (Some(key), Some(status)) = (idempotency_key, idempotency_status) {
wrapped = wrapped.with_idempotency(
key,
match status {
crate::idempotency::IdempotencyStatus::Executed => "executed".to_string(),
crate::idempotency::IdempotencyStatus::Replayed => "replayed".to_string(),
},
);
}
serde_json::to_string_pretty(&wrapped).unwrap()
};
println!("{}", output);
Ok(())
}
pub async fn run_react_remove(args: &[String], non_interactive: bool) -> Result<(), String> {
use crate::idempotency::{IdempotencyCheckResult, IdempotencyHandler};
if args.len() < 6 {
return Err(
"Usage: react remove <channel> <ts> <emoji> [--yes] [--profile=NAME] [--token-type=bot|user] [--idempotency-key=KEY]".to_string(),
);
}
let channel = args[3].clone();
let ts = args[4].clone();
let emoji = args[5].clone();
let yes = has_flag(args, "--yes");
let profile_name = resolve_profile_name(args);
let token_type = parse_token_type(args)?;
let idempotency_key = get_option(args, "--idempotency-key=");
let raw = should_output_raw(args);
let client = get_api_client_with_token_type(Some(profile_name.clone()), token_type).await?;
let (response_value, idempotency_status) = if let Some(key) = idempotency_key.clone() {
let mut handler = IdempotencyHandler::new().map_err(|e| e.to_string())?;
let mut params = serde_json::Map::new();
params.insert("channel".to_string(), serde_json::json!(channel.clone()));
params.insert("timestamp".to_string(), serde_json::json!(ts.clone()));
params.insert("name".to_string(), serde_json::json!(emoji.clone()));
let (team_id, user_id) = get_team_and_user_ids_from_profile(&profile_name).await?;
match handler
.check(
Some(key.clone()),
team_id,
user_id,
"reactions.remove".to_string(),
¶ms,
)
.map_err(|e| e.to_string())?
{
IdempotencyCheckResult::Replay {
response, status, ..
} => (response, Some(status)),
IdempotencyCheckResult::Execute {
key: scoped_key,
fingerprint,
} => {
let response =
commands::react_remove(&client, channel, ts, emoji, yes, non_interactive)
.await
.map_err(|e| e.to_string())?;
let response_value = serde_json::to_value(&response).map_err(|e| e.to_string())?;
handler
.store(scoped_key, fingerprint, response_value.clone())
.map_err(|e| e.to_string())?;
(
response_value,
Some(crate::idempotency::IdempotencyStatus::Executed),
)
}
IdempotencyCheckResult::NoKey => unreachable!(),
}
} else {
let response = commands::react_remove(&client, channel, ts, emoji, yes, non_interactive)
.await
.map_err(|e| e.to_string())?;
(
serde_json::to_value(&response).map_err(|e| e.to_string())?,
None,
)
};
if let Ok(api_response) =
serde_json::from_value::<crate::api::ApiResponse>(response_value.clone())
{
crate::api::display_wrapper_error_guidance(&api_response);
}
let output = if raw {
serde_json::to_string_pretty(&response_value).unwrap()
} else {
let mut wrapped = wrap_with_envelope_and_token_type(
response_value,
"reactions.remove",
"react remove",
Some(profile_name),
token_type,
)
.await?;
if let (Some(key), Some(status)) = (idempotency_key, idempotency_status) {
wrapped = wrapped.with_idempotency(
key,
match status {
crate::idempotency::IdempotencyStatus::Executed => "executed".to_string(),
crate::idempotency::IdempotencyStatus::Replayed => "replayed".to_string(),
},
);
}
serde_json::to_string_pretty(&wrapped).unwrap()
};
println!("{}", output);
Ok(())
}
pub async fn run_file_upload(args: &[String], non_interactive: bool) -> Result<(), String> {
use crate::idempotency::{IdempotencyCheckResult, IdempotencyHandler};
if args.len() < 4 {
return Err(
"Usage: file upload <path> [--channel=ID] [--channels=IDs] [--title=TITLE] [--comment=TEXT] [--yes] [--profile=NAME] [--token-type=bot|user] [--idempotency-key=KEY]"
.to_string(),
);
}
let file_path = args[3].clone();
let channels = get_option(args, "--channel=").or_else(|| get_option(args, "--channels="));
let title = get_option(args, "--title=");
let comment = get_option(args, "--comment=");
let yes = has_flag(args, "--yes");
let profile_name = resolve_profile_name(args);
let token_type = parse_token_type(args)?;
let idempotency_key = get_option(args, "--idempotency-key=");
let raw = should_output_raw(args);
let client = get_api_client_with_token_type(Some(profile_name.clone()), token_type).await?;
let (response_value, idempotency_status) = if let Some(key) = idempotency_key.clone() {
let mut handler = IdempotencyHandler::new().map_err(|e| e.to_string())?;
let mut params = serde_json::Map::new();
params.insert("filename".to_string(), serde_json::json!(file_path.clone()));
if let Some(ref ch) = channels {
params.insert("channels".to_string(), serde_json::json!(ch));
}
if let Some(ref t) = title {
params.insert("title".to_string(), serde_json::json!(t));
}
if let Some(ref c) = comment {
params.insert("comment".to_string(), serde_json::json!(c));
}
let (team_id, user_id) = get_team_and_user_ids_from_profile(&profile_name).await?;
match handler
.check(
Some(key.clone()),
team_id,
user_id,
"files.upload".to_string(),
¶ms,
)
.map_err(|e| e.to_string())?
{
IdempotencyCheckResult::Replay {
response, status, ..
} => (response, Some(status)),
IdempotencyCheckResult::Execute {
key: scoped_key,
fingerprint,
} => {
let response = commands::file_upload(
&client,
file_path,
channels,
title,
comment,
yes,
non_interactive,
)
.await
.map_err(|e| e.to_string())?;
let response_value = serde_json::to_value(&response).map_err(|e| e.to_string())?;
handler
.store(scoped_key, fingerprint, response_value.clone())
.map_err(|e| e.to_string())?;
(
response_value,
Some(crate::idempotency::IdempotencyStatus::Executed),
)
}
IdempotencyCheckResult::NoKey => unreachable!(),
}
} else {
let response = commands::file_upload(
&client,
file_path,
channels,
title,
comment,
yes,
non_interactive,
)
.await
.map_err(|e| e.to_string())?;
(
serde_json::to_value(&response).map_err(|e| e.to_string())?,
None,
)
};
crate::api::display_json_error_guidance(&response_value);
let output = if raw {
serde_json::to_string_pretty(&response_value).unwrap()
} else {
let mut wrapped = wrap_with_envelope_and_token_type(
response_value,
"files.upload",
"file upload",
Some(profile_name),
token_type,
)
.await?;
if let (Some(key), Some(status)) = (idempotency_key, idempotency_status) {
wrapped = wrapped.with_idempotency(
key,
match status {
crate::idempotency::IdempotencyStatus::Executed => "executed".to_string(),
crate::idempotency::IdempotencyStatus::Replayed => "replayed".to_string(),
},
);
}
serde_json::to_string_pretty(&wrapped).unwrap()
};
println!("{}", output);
Ok(())
}
pub async fn run_file_download(args: &[String]) -> Result<(), String> {
if args.len() < 3 {
return Err(
"Usage: file download [<file_id>] [--url=URL] [--out=PATH] [--profile=NAME] [--token-type=bot|user]"
.to_string(),
);
}
let file_id = args.get(3).filter(|arg| !arg.starts_with("--")).cloned();
let url = get_option(args, "--url=");
let out = get_option(args, "--out=");
let profile_name = resolve_profile_name(args);
let token_type = parse_token_type(args)?;
let raw = should_output_raw(args);
if file_id.is_none() && url.is_none() {
return Err("Either <file_id> or --url must be provided".to_string());
}
let client = get_api_client_with_token_type(Some(profile_name.clone()), token_type).await?;
let response = commands::file_download(&client, file_id, url, out)
.await
.map_err(|e| e.to_string())?;
if let Some(out_path) = response.get("output").and_then(|v| v.as_str()) {
if out_path == "-" {
return Ok(());
}
}
crate::api::display_json_error_guidance(&response);
let output = if raw {
serde_json::to_string_pretty(&response).unwrap()
} else {
let wrapped = wrap_with_envelope_and_token_type(
response,
"files.info + download",
"file download",
Some(profile_name),
token_type,
)
.await?;
serde_json::to_string_pretty(&wrapped).unwrap()
};
println!("{}", output);
Ok(())
}
pub fn print_conv_usage(prog: &str) {
println!("Conv command usage:");
println!(
" {} conv list [--types=TYPE] [--include-private] [--all] [--limit=N] [--filter=KEY:VALUE]... [--format=FORMAT] [--sort=KEY] [--sort-dir=DIR] [--raw] [--profile=NAME] [--token-type=bot|user]",
prog
);
println!(" List conversations with optional filtering and sorting");
println!(" Options accept both --option=value and --option value formats");
println!(" Default: Includes public and private channels (limit=1000, auto-paginated)");
println!(" Type shortcuts (mutually exclusive with --types):");
println!(" - --include-private: Include private channels (same as default now)");
println!(
" - --all: Include all conversation types (public_channel,private_channel,im,mpim)"
);
println!(" Filters: name:<glob>, is_member:true|false, is_private:true|false");
println!(" - name:<glob>: Filter by channel name (supports * and ? wildcards)");
println!(" - is_member:true|false: Filter by membership status");
println!(" - is_private:true|false: Filter by channel privacy");
println!(" Formats: json (default), jsonl, table, tsv");
println!(" - json: JSON format with envelope (use --raw for raw Slack API response)");
println!(" - jsonl: JSON Lines format (one object per line)");
println!(" - table: Human-readable table format");
println!(" - tsv: Tab-separated values");
println!(" Sort keys: name, created, num_members");
println!(" - name: Sort by channel name");
println!(" - created: Sort by creation timestamp");
println!(" - num_members: Sort by member count");
println!(" Sort direction: asc (default), desc");
println!(" Note: --raw is only valid with --format json");
println!();
println!(
" {} conv search <pattern> [--select] [--types=TYPE] [--limit=N] [--filter=KEY:VALUE]... [--format=FORMAT] [--sort=KEY] [--sort-dir=DIR] [--raw] [--profile=NAME] [--token-type=bot|user]",
prog
);
println!(" Search conversations by name pattern (applies name:<pattern> filter)");
println!(" Default: Includes public and private channels (limit=1000, auto-paginated)");
println!(" Options accept both --option=value and --option value formats");
println!(" --select: Interactively select from results and output channel ID only");
println!();
println!(
" {} conv select [--types=TYPE] [--filter=KEY:VALUE]... [--profile=NAME]",
prog
);
println!(" Interactively select a conversation and output its channel ID");
println!(" Default: Includes public and private channels (limit=1000, auto-paginated)");
println!(" Options accept both --option=value and --option value formats");
println!();
println!(
" {} conv history <channel> [--limit=N] [--oldest=TS] [--latest=TS] [--profile=NAME] [--token-type=bot|user]",
prog
);
println!(
" {} conv history --interactive [--types=TYPE] [--filter=KEY:VALUE]... [--limit=N] [--profile=NAME]",
prog
);
println!(" Select channel interactively before fetching history");
println!(" Default: Includes public and private channels (limit=1000, auto-paginated)");
println!(" Options accept both --option=value and --option value formats");
}
pub fn print_thread_usage(prog: &str) {
println!("Thread command usage:");
println!(
" {} thread get <channel> <thread_ts> [--limit=N] [--inclusive] [--raw] [--profile=NAME] [--token-type=bot|user]",
prog
);
println!(" Get thread messages (conversation replies) for a specific thread");
println!(" Arguments:");
println!(" <channel> - Channel ID containing the thread");
println!(" <thread_ts> - Timestamp of the parent message (thread identifier)");
println!(" Options:");
println!(" --limit=N - Number of messages per page (default: 100)");
println!(" --inclusive - Include the parent message in results");
println!(" --raw - Output raw Slack API response without envelope");
println!(" --profile=NAME - Profile to use (default: 'default')");
println!(" --token-type=TYPE - Token type to use (bot or user)");
println!(" Note: Automatically follows pagination to retrieve all thread messages");
}
pub fn print_users_usage(prog: &str) {
println!("Users command usage:");
println!(
" {} users info <user_id> [--profile=NAME] [--token-type=bot|user]",
prog
);
println!(
" {} users cache-update [--profile=NAME] [--force] [--token-type=bot|user]",
prog
);
println!(" {} users resolve-mentions <text> [--profile=NAME] [--format=display_name|real_name|username]", prog);
println!(" Options accept both --option=value and --option value formats");
}
pub fn print_msg_usage(prog: &str) {
println!("Msg command usage:");
println!(
" {} msg post <channel> <text> [--thread-ts=TS] [--reply-broadcast] [--idempotency-key=KEY] [--profile=NAME] [--token-type=bot|user]",
prog
);
println!(" Requires SLACKCLI_ALLOW_WRITE=true environment variable");
println!(
" {} msg update <channel> <ts> <text> [--yes] [--idempotency-key=KEY] [--profile=NAME] [--token-type=bot|user]",
prog
);
println!(" Requires SLACKCLI_ALLOW_WRITE=true environment variable");
println!(
" {} msg delete <channel> <ts> [--yes] [--idempotency-key=KEY] [--profile=NAME] [--token-type=bot|user]",
prog
);
println!(" Requires SLACKCLI_ALLOW_WRITE=true environment variable");
println!(" Options accept both --option=value and --option value formats");
println!(" --idempotency-key: Prevent duplicate writes (replays stored result on retry)");
}
pub fn print_react_usage(prog: &str) {
println!("React command usage:");
println!(
" {} react add <channel> <ts> <emoji> [--idempotency-key=KEY] [--profile=NAME] [--token-type=bot|user]",
prog
);
println!(" Requires SLACKCLI_ALLOW_WRITE=true environment variable");
println!(
" {} react remove <channel> <ts> <emoji> [--yes] [--idempotency-key=KEY] [--profile=NAME] [--token-type=bot|user]",
prog
);
println!(" Requires SLACKCLI_ALLOW_WRITE=true environment variable");
println!(" Options accept both --option=value and --option value formats");
println!(" --idempotency-key: Prevent duplicate writes (replays stored result on retry)");
}
pub fn print_file_usage(prog: &str) {
println!("File command usage:");
println!(
" {} file upload <path> [--channel=ID] [--channels=IDs] [--title=TITLE] [--comment=TEXT] [--idempotency-key=KEY] [--profile=NAME] [--token-type=bot|user]",
prog
);
println!(" Upload a file using external upload method");
println!(" Requires SLACKCLI_ALLOW_WRITE=true environment variable");
println!(
" {} file download [<file_id>] [--url=URL] [--out=PATH] [--profile=NAME] [--token-type=bot|user]",
prog
);
println!(" Download a file from Slack");
println!(" Either <file_id> or --url must be provided");
println!(" --out: Output path (omit for current directory, '-' for stdout, directory for auto-naming)");
println!(" Options accept both --option=value and --option value formats");
println!(" --idempotency-key: Prevent duplicate writes (replays stored result on retry, upload only)");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_token_type_equals_format() {
let args = vec!["command".to_string(), "--token-type=user".to_string()];
let result = parse_token_type(&args).unwrap();
assert_eq!(result, Some(TokenType::User));
}
#[test]
fn test_parse_token_type_space_separated() {
let args = vec![
"command".to_string(),
"--token-type".to_string(),
"bot".to_string(),
];
let result = parse_token_type(&args).unwrap();
assert_eq!(result, Some(TokenType::Bot));
}
#[test]
fn test_parse_token_type_both_values() {
let args1 = vec!["--token-type=user".to_string()];
assert_eq!(parse_token_type(&args1).unwrap(), Some(TokenType::User));
let args2 = vec!["--token-type=bot".to_string()];
assert_eq!(parse_token_type(&args2).unwrap(), Some(TokenType::Bot));
let args3 = vec!["--token-type".to_string(), "user".to_string()];
assert_eq!(parse_token_type(&args3).unwrap(), Some(TokenType::User));
let args4 = vec!["--token-type".to_string(), "bot".to_string()];
assert_eq!(parse_token_type(&args4).unwrap(), Some(TokenType::Bot));
}
#[test]
fn test_parse_token_type_missing() {
let args = vec!["command".to_string()];
let result = parse_token_type(&args).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_parse_token_type_missing_value() {
let args = vec!["--token-type".to_string()];
let result = parse_token_type(&args);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
"--token-type requires a value (bot or user)"
);
}
#[test]
fn test_parse_token_type_invalid_value() {
let args = vec!["--token-type=invalid".to_string()];
let result = parse_token_type(&args);
assert!(result.is_err());
}
struct MockTokenStore {
tokens: std::collections::HashMap<String, String>,
}
impl MockTokenStore {
fn new() -> Self {
Self {
tokens: std::collections::HashMap::new(),
}
}
fn with_token(mut self, key: &str, value: &str) -> Self {
self.tokens.insert(key.to_string(), value.to_string());
self
}
}
impl TokenStore for MockTokenStore {
fn get(&self, key: &str) -> crate::profile::token_store::Result<String> {
use crate::profile::token_store::TokenStoreError;
self.tokens
.get(key)
.cloned()
.ok_or_else(|| TokenStoreError::NotFound(key.to_string()))
}
fn set(&self, _key: &str, _value: &str) -> crate::profile::token_store::Result<()> {
unimplemented!("set not needed for tests")
}
fn delete(&self, _key: &str) -> crate::profile::token_store::Result<()> {
unimplemented!("delete not needed for tests")
}
fn exists(&self, key: &str) -> bool {
self.tokens.contains_key(key)
}
}
#[test]
fn test_resolve_token_prefers_env() {
let store = MockTokenStore::new().with_token("T123:U123", "xoxb-store-token");
let result = resolve_token_for_wrapper(
Some("xoxb-env-token".to_string()),
&store,
"T123:U123",
None,
false,
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "xoxb-env-token");
}
#[test]
fn test_resolve_token_uses_store() {
let store = MockTokenStore::new().with_token("T123:U123", "xoxb-store-token");
let result = resolve_token_for_wrapper(None, &store, "T123:U123", None, false);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "xoxb-store-token");
}
#[test]
fn test_resolve_token_explicit_request() {
let store = MockTokenStore::new().with_token("T123:U123", "xoxb-bot-token");
let result = resolve_token_for_wrapper(
None,
&store,
"T123:U123:user", Some("T123:U123"), true, );
assert!(result.is_err());
assert!(result.unwrap_err().contains("explicitly requested"));
}
#[test]
fn test_resolve_token_fallback_when_not_explicit() {
let store = MockTokenStore::new().with_token("T123:U123", "xoxb-bot-token");
let result = resolve_token_for_wrapper(
None,
&store,
"T123:U123:user", Some("T123:U123"), false, );
assert!(result.is_ok());
assert_eq!(result.unwrap(), "xoxb-bot-token");
}
#[test]
fn test_resolve_token_env_overrides_explicit() {
let store = MockTokenStore::new()
.with_token("T123:U123", "xoxb-bot-token")
.with_token("T123:U123:user", "xoxp-user-token");
let result = resolve_token_for_wrapper(
Some("xoxb-env-token".to_string()),
&store,
"T123:U123:user",
None,
true, );
assert!(result.is_ok());
assert_eq!(result.unwrap(), "xoxb-env-token");
}
#[test]
fn test_get_option_equals_format() {
let args = vec!["cmd".to_string(), "--filter=is_private:true".to_string()];
assert_eq!(
get_option(&args, "--filter="),
Some("is_private:true".to_string())
);
}
#[test]
fn test_get_option_space_separated() {
let args = vec![
"cmd".to_string(),
"--filter".to_string(),
"is_private:true".to_string(),
];
assert_eq!(
get_option(&args, "--filter="),
Some("is_private:true".to_string())
);
}
#[test]
fn test_get_option_space_separated_rejects_dash_value() {
let args = vec![
"cmd".to_string(),
"--filter".to_string(),
"--other".to_string(),
];
assert_eq!(get_option(&args, "--filter="), None);
}
#[test]
fn test_get_option_space_separated_missing_value() {
let args = vec!["cmd".to_string(), "--filter".to_string()];
assert_eq!(get_option(&args, "--filter="), None);
}
#[test]
fn test_get_option_prefers_equals_format() {
let args = vec![
"--filter=value1".to_string(),
"--filter".to_string(),
"value2".to_string(),
];
assert_eq!(get_option(&args, "--filter="), Some("value1".to_string()));
}
#[test]
fn test_get_all_options_equals_format() {
let args = vec![
"cmd".to_string(),
"--filter=is_private:true".to_string(),
"--filter=is_member:true".to_string(),
];
let result = get_all_options(&args, "--filter=");
assert_eq!(result, vec!["is_private:true", "is_member:true"]);
}
#[test]
fn test_get_all_options_space_separated() {
let args = vec![
"cmd".to_string(),
"--filter".to_string(),
"is_private:true".to_string(),
"--filter".to_string(),
"is_member:true".to_string(),
];
let result = get_all_options(&args, "--filter=");
assert_eq!(result, vec!["is_private:true", "is_member:true"]);
}
#[test]
fn test_get_all_options_mixed_format() {
let args = vec![
"cmd".to_string(),
"--filter=is_private:true".to_string(),
"--filter".to_string(),
"is_member:true".to_string(),
"--filter=name:test".to_string(),
"--filter".to_string(),
"is_archived:false".to_string(),
];
let result = get_all_options(&args, "--filter=");
assert_eq!(
result,
vec![
"is_private:true",
"name:test",
"is_member:true",
"is_archived:false"
]
);
}
#[test]
fn test_get_all_options_rejects_dash_values() {
let args = vec![
"cmd".to_string(),
"--filter=value1".to_string(),
"--filter".to_string(),
"--other".to_string(), "--filter".to_string(),
"value2".to_string(),
];
let result = get_all_options(&args, "--filter=");
assert_eq!(result, vec!["value1", "value2"]);
}
#[test]
fn test_get_all_options_space_separated_at_end() {
let args = vec![
"cmd".to_string(),
"--filter=value1".to_string(),
"--filter".to_string(),
];
let result = get_all_options(&args, "--filter=");
assert_eq!(result, vec!["value1"]);
}
#[test]
fn test_conv_list_filter_space_separated() {
let args = vec![
"slack".to_string(),
"conv".to_string(),
"list".to_string(),
"--filter".to_string(),
"is_private:true".to_string(),
];
let filters = get_all_options(&args, "--filter=");
assert_eq!(filters.len(), 1);
assert_eq!(filters[0], "is_private:true");
}
#[test]
fn test_conv_list_multiple_filters_mixed() {
let args = vec![
"slack".to_string(),
"conv".to_string(),
"list".to_string(),
"--filter=is_private:true".to_string(),
"--filter".to_string(),
"is_member:true".to_string(),
];
let filters = get_all_options(&args, "--filter=");
assert_eq!(filters.len(), 2);
assert_eq!(filters[0], "is_private:true");
assert_eq!(filters[1], "is_member:true");
}
#[test]
fn test_conv_search_options_space_separated() {
let args = vec![
"slack".to_string(),
"conv".to_string(),
"search".to_string(),
"pattern".to_string(),
"--format".to_string(),
"table".to_string(),
"--sort".to_string(),
"name".to_string(),
];
assert_eq!(get_option(&args, "--format="), Some("table".to_string()));
assert_eq!(get_option(&args, "--sort="), Some("name".to_string()));
}
#[test]
fn test_search_command_options_space_separated() {
let args = vec![
"slack".to_string(),
"search".to_string(),
"query".to_string(),
"--count".to_string(),
"10".to_string(),
"--sort".to_string(),
"timestamp".to_string(),
];
assert_eq!(get_option(&args, "--count="), Some("10".to_string()));
assert_eq!(get_option(&args, "--sort="), Some("timestamp".to_string()));
}
#[test]
fn test_resolve_profile_name_with_equals_format() {
let args = vec![
"slack".to_string(),
"api".to_string(),
"call".to_string(),
"--profile=myprofile".to_string(),
"test.method".to_string(),
];
assert_eq!(resolve_profile_name(&args), "myprofile");
}
#[test]
fn test_resolve_profile_name_with_space_format() {
let args = vec![
"slack".to_string(),
"api".to_string(),
"call".to_string(),
"--profile".to_string(),
"myprofile".to_string(),
"test.method".to_string(),
];
assert_eq!(resolve_profile_name(&args), "myprofile");
}
#[test]
fn test_resolve_profile_name_at_beginning() {
let args = vec![
"slack".to_string(),
"--profile=myprofile".to_string(),
"api".to_string(),
"call".to_string(),
"test.method".to_string(),
];
assert_eq!(resolve_profile_name(&args), "myprofile");
}
#[test]
fn test_resolve_profile_name_at_end() {
let args = vec![
"slack".to_string(),
"api".to_string(),
"call".to_string(),
"test.method".to_string(),
"--profile=myprofile".to_string(),
];
assert_eq!(resolve_profile_name(&args), "myprofile");
}
#[test]
#[serial_test::serial]
fn test_resolve_profile_name_env_fallback() {
std::env::set_var("SLACK_PROFILE", "envprofile");
let args = vec!["slack".to_string(), "api".to_string(), "call".to_string()];
assert_eq!(resolve_profile_name(&args), "envprofile");
std::env::remove_var("SLACK_PROFILE");
}
#[test]
#[serial_test::serial]
fn test_resolve_profile_name_default_fallback() {
std::env::remove_var("SLACK_PROFILE");
let args = vec!["slack".to_string(), "api".to_string(), "call".to_string()];
assert_eq!(resolve_profile_name(&args), "default");
}
#[test]
#[serial_test::serial]
fn test_resolve_profile_name_flag_overrides_env() {
std::env::set_var("SLACK_PROFILE", "envprofile");
let args = vec![
"slack".to_string(),
"api".to_string(),
"--profile=flagprofile".to_string(),
"call".to_string(),
];
assert_eq!(resolve_profile_name(&args), "flagprofile");
std::env::remove_var("SLACK_PROFILE");
}
#[test]
#[serial_test::serial]
fn test_resolve_profile_name_priority_all_sources() {
std::env::set_var("SLACK_PROFILE", "envprofile");
let args = vec![
"--profile".to_string(),
"flagprofile".to_string(),
"slack".to_string(),
"api".to_string(),
"call".to_string(),
];
assert_eq!(resolve_profile_name(&args), "flagprofile");
std::env::remove_var("SLACK_PROFILE");
}
#[test]
fn test_resolve_profile_name_mixed_formats() {
let args = vec![
"slack".to_string(),
"--profile=profile1".to_string(),
"api".to_string(),
"--profile".to_string(),
"profile2".to_string(),
"call".to_string(),
];
assert_eq!(resolve_profile_name(&args), "profile1");
}
#[test]
fn test_conv_list_include_private_flag() {
let args = vec![
"slack".to_string(),
"conv".to_string(),
"list".to_string(),
"--include-private".to_string(),
];
assert!(has_flag(&args, "--include-private"));
assert!(!has_flag(&args, "--all"));
}
#[test]
fn test_conv_list_all_flag() {
let args = vec![
"slack".to_string(),
"conv".to_string(),
"list".to_string(),
"--all".to_string(),
];
assert!(!has_flag(&args, "--include-private"));
assert!(has_flag(&args, "--all"));
}
#[test]
fn test_conv_list_types_exclude_private_all() {
let args_with_types = vec![
"slack".to_string(),
"conv".to_string(),
"list".to_string(),
"--types=public_channel".to_string(),
];
assert_eq!(
get_option(&args_with_types, "--types="),
Some("public_channel".to_string())
);
let args_with_private = vec![
"slack".to_string(),
"conv".to_string(),
"list".to_string(),
"--types=public_channel".to_string(),
"--include-private".to_string(),
];
assert_eq!(
get_option(&args_with_private, "--types="),
Some("public_channel".to_string())
);
assert!(has_flag(&args_with_private, "--include-private"));
}
#[test]
fn test_conv_list_types_resolution_logic() {
let args_no_flags = vec!["slack".to_string(), "conv".to_string(), "list".to_string()];
let types = get_option(&args_no_flags, "--types=");
let include_private = has_flag(&args_no_flags, "--include-private");
let all = has_flag(&args_no_flags, "--all");
assert!(types.is_none());
assert!(!include_private);
assert!(!all);
let args_private = vec![
"slack".to_string(),
"conv".to_string(),
"list".to_string(),
"--include-private".to_string(),
];
let types = get_option(&args_private, "--types=");
let include_private = has_flag(&args_private, "--include-private");
let all = has_flag(&args_private, "--all");
assert!(types.is_none());
assert!(include_private);
assert!(!all);
let args_all = vec![
"slack".to_string(),
"conv".to_string(),
"list".to_string(),
"--all".to_string(),
];
let types = get_option(&args_all, "--types=");
let include_private = has_flag(&args_all, "--include-private");
let all = has_flag(&args_all, "--all");
assert!(types.is_none());
assert!(!include_private);
assert!(all);
let args_conflict1 = vec![
"slack".to_string(),
"conv".to_string(),
"list".to_string(),
"--types=public_channel".to_string(),
"--include-private".to_string(),
];
let types = get_option(&args_conflict1, "--types=");
let include_private = has_flag(&args_conflict1, "--include-private");
assert!(types.is_some());
assert!(include_private);
let args_conflict2 = vec![
"slack".to_string(),
"conv".to_string(),
"list".to_string(),
"--types=public_channel".to_string(),
"--all".to_string(),
];
let types = get_option(&args_conflict2, "--types=");
let all = has_flag(&args_conflict2, "--all");
assert!(types.is_some());
assert!(all);
}
}