#![feature(coverage_attribute)]
use std::{
io::IsTerminal as _,
path::{Path, PathBuf},
};
use anyhow::Context as _;
use clap::{Args, CommandFactory as _, Parser, Subcommand};
use conclavelib::{
base::{PermissionLevel, Res, Visibility, Void},
control,
identity::{self, Identity, PermissionOverride, ServerRegistration},
protocol::{AdminOp, ProtocolMessage},
skill,
};
use tracing::error;
#[coverage(off)]
#[tokio::main]
async fn main() {
let cli = Cli::parse();
if !matches!(cli.command, Command::Serve(_) | Command::Bridge(_)) {
restore_default_sigpipe();
}
let directive = log_directive(cli.verbose, std::env::var("RUST_LOG").ok().as_deref());
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_ansi(std::io::stderr().is_terminal())
.with_level(true)
.with_target(false)
.with_env_filter(tracing_subscriber::EnvFilter::new(directive))
.init();
if let Err(err) = execute(&cli).await {
error!("❌ {err:#}");
std::process::exit(1);
}
}
#[cfg(unix)]
fn restore_default_sigpipe() {
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
}
#[cfg(not(unix))]
fn restore_default_sigpipe() {}
fn log_directive(verbose: bool, rust_log: Option<&str>) -> String {
match rust_log.filter(|value| !value.is_empty()) {
Some(directive) => directive.to_owned(),
None if verbose => "debug".to_owned(),
None => "info".to_owned(),
}
}
async fn execute(cli: &Cli) -> Void {
let dir = cli.config_dir.as_ref();
match &cli.command {
Command::Serve(args) => {
if args.data_dir.is_none() && !args.ephemeral {
anyhow::bail!("`serve` requires `--data-dir <path>` for persistent storage (or `--ephemeral` for a throwaway in-memory store)");
}
conclavelib::server::serve(conclavelib::server::ServerConfig {
bind: args.bind.clone(),
data_dir: args.data_dir.clone(),
admins: args.admins.iter().map(|spec| parse_admin_binding(spec)).collect(),
})
.await
}
Command::Bridge(args) => run_bridge(dir, args).await,
Command::Key => run_key(dir),
Command::Register(args) => run_register(dir, args).await,
Command::Machine { command } => run_machine(dir, command).await,
Command::Server { command } => run_server(dir, command),
Command::Join(args) => run_join(dir, args).await,
Command::Perm { command } => run_perm(dir, command),
Command::Channel { command } => run_channel(dir, command).await,
Command::Acl { command } => run_acl(dir, command).await,
Command::Invite { command } => run_invite(dir, command).await,
Command::Status => run_status(dir).await,
Command::Send(args) => {
control::send_message(&args.server, &load_identity(dir)?, &cli_session(), &args.channel, &args.text).await?;
println!("✓ sent to {}", args.channel);
Ok(())
}
Command::Tail(args) => control::tail(&args.server, &load_identity(dir)?, &args.session.clone().unwrap_or_else(cli_session), &args.channel).await,
Command::Who(args) => print_response(control::one_shot(&args.server, &load_identity(dir)?, &cli_session(), ProtocolMessage::Who { channel: args.channel.clone() }).await?),
Command::Kick(args) => {
admin_op(
dir,
&args.server,
AdminOp::Kick {
channel: args.channel.clone(),
target: args.target.clone(),
},
)
.await
}
Command::Ban(args) => {
admin_op(
dir,
&args.server,
AdminOp::Ban {
channel: args.channel.clone(),
user: args.user.clone(),
},
)
.await
}
Command::Unban(args) => {
admin_op(
dir,
&args.server,
AdminOp::Unban {
channel: args.channel.clone(),
user: args.user.clone(),
},
)
.await
}
Command::Bans(args) => admin_op(dir, &args.server, AdminOp::BanList { channel: args.channel.clone() }).await,
Command::User { command } => run_user(dir, command).await,
Command::Skill(args) => run_skill(args),
Command::Completions { shell } => {
clap_complete::generate(*shell, &mut Cli::command(), "conclave", &mut std::io::stdout());
Ok(())
}
}
}
fn config_dir(explicit: Option<&PathBuf>) -> Res<PathBuf> {
match explicit {
Some(dir) => Ok(dir.clone()),
None => identity::default_config_dir(),
}
}
fn cli_session() -> String {
format!("cli-{}", std::process::id())
}
fn load_identity(explicit: Option<&PathBuf>) -> Res<Identity> {
identity::load_identity(&config_dir(explicit)?)
}
fn load_or_create_identity(dir: &Path) -> Res<Identity> {
if dir.join("key").exists() {
identity::load_identity(dir)
} else {
let identity = Identity::generate()?;
identity::save_identity(dir, &identity)?;
Ok(identity)
}
}
async fn admin_op(explicit: Option<&PathBuf>, server: &str, op: AdminOp) -> Void {
print_response(control::one_shot(server, &load_identity(explicit)?, &cli_session(), ProtocolMessage::Admin(op)).await?)
}
fn print_response(response: ProtocolMessage) -> Void {
match response {
ProtocolMessage::Ack { detail } => println!("✓ {}", detail.unwrap_or_else(|| "ok".to_owned())),
ProtocolMessage::Joined { channel } => println!("✓ joined {channel}"),
ProtocolMessage::InviteToken { token } => println!("invite token: {token}"),
ProtocolMessage::Established { path } => println!("✓ {path}"),
ProtocolMessage::ChannelList { channels } => {
for channel in channels {
println!("{}\t{}{}", channel.name, channel.visibility.as_str(), if channel.member { "\t(member)" } else { "" });
}
}
ProtocolMessage::MachineList { machines } => {
for machine in machines {
println!("{}\t{}\t{}", machine.name, machine.pubkey, machine.added_at);
}
}
ProtocolMessage::UserList { users } => {
for user in users {
println!("{user}");
}
}
ProtocolMessage::InviteList { invites } => {
for invite in invites {
let uses = invite.uses_remaining.map_or_else(|| "unlimited".to_owned(), |u| u.to_string());
let expires = invite.expires_at.unwrap_or_else(|| "never".to_owned());
println!("{}\tuses: {uses}\texpires: {expires}", invite.token);
}
}
ProtocolMessage::Presence { channel, sessions } => {
let scope = channel.unwrap_or_else(|| "server".to_owned());
let who = sessions.iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(", ");
println!("[{scope}] {who}");
}
ProtocolMessage::Error(err) => anyhow::bail!("{err}"),
other => anyhow::bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn run_key(explicit: Option<&PathBuf>) -> Void {
let identity = load_or_create_identity(&config_dir(explicit)?)?;
println!("{}", identity.public_key_base64());
Ok(())
}
async fn run_register(explicit: Option<&PathBuf>, args: &RegisterArgs) -> Void {
let dir = config_dir(explicit)?;
let identity = load_or_create_identity(&dir)?;
let machine = args.machine.clone().unwrap_or_else(default_machine_name);
let path = control::register(&args.server, &identity, &args.username, &machine, &cli_session()).await?;
let mut config = identity::load_config(&dir)?;
config.servers.retain(|s| s.url != args.server);
config.servers.push(ServerRegistration {
url: args.server.clone(),
username: args.username.clone(),
machine,
});
identity::save_config(&dir, &config)?;
println!("✓ registered {path}");
Ok(())
}
async fn run_machine(explicit: Option<&PathBuf>, command: &MachineCommand) -> Void {
match command {
MachineCommand::Add { server, name, pubkey } => {
let pubkey = identity::decode_key(pubkey).map_err(|e| anyhow::anyhow!("invalid public key: {e}"))?;
admin_op(explicit, server, AdminOp::MachineAdd { name: name.clone(), pubkey }).await
}
MachineCommand::List { server } => print_response(control::one_shot(server, &load_identity(explicit)?, &cli_session(), ProtocolMessage::ListMachines).await?),
MachineCommand::Remove { server, name } => admin_op(explicit, server, AdminOp::MachineRemove { name: name.clone() }).await,
}
}
async fn run_join(explicit: Option<&PathBuf>, args: &JoinArgs) -> Void {
let dir = config_dir(explicit)?;
let identity = load_identity(explicit)?;
let perm = args.perm.as_deref().map(str::parse::<PermissionLevel>).transpose().map_err(anyhow::Error::from)?;
let response = control::one_shot(
&args.server,
&identity,
&cli_session(),
ProtocolMessage::Join {
channel: args.channel.clone(),
token: args.token.clone(),
},
)
.await?;
print_response(response)?;
if let Some(level) = perm {
let mut config = identity::load_config(&dir)?;
config.overrides.retain(|o| !(o.server == args.server && o.channel.as_deref() == Some(args.channel.as_str())));
config.overrides.push(PermissionOverride {
server: args.server.clone(),
channel: Some(args.channel.clone()),
level,
});
identity::save_config(&dir, &config)?;
}
eprintln!("note: verified access and set the local permission; your live session subscribes via the /conclave skill's join_channel tool.");
Ok(())
}
fn run_server(explicit: Option<&PathBuf>, command: &ServerCommand) -> Void {
let dir = config_dir(explicit)?;
match command {
ServerCommand::List => {
let config = identity::load_config(&dir)?;
if config.servers.is_empty() {
println!("no servers registered (see `conclave register`)");
}
for registration in &config.servers {
println!("{}\t{}/{}", registration.url, registration.username, registration.machine);
}
}
ServerCommand::Remove { url } => {
let mut config = identity::load_config(&dir)?;
let servers_before = config.servers.len();
config.servers.retain(|r| r.url != *url);
anyhow::ensure!(config.servers.len() < servers_before, "no registration for `{url}` (see `conclave server list`)");
let overrides_before = config.overrides.len();
config.overrides.retain(|o| o.server != *url);
identity::save_config(&dir, &config)?;
println!("✓ forgot `{url}` ({} permission override(s) removed)", overrides_before - config.overrides.len());
}
}
Ok(())
}
fn run_perm(explicit: Option<&PathBuf>, command: &PermCommand) -> Void {
let dir = config_dir(explicit)?;
match command {
PermCommand::Set { level, server, channel, whisper } => {
let level: PermissionLevel = level.parse().map_err(anyhow::Error::from)?;
let mut config = identity::load_config(&dir)?;
if let Some(server) = server {
let scope_channel = match (channel, *whisper) {
(Some(channel), false) => Some(channel.clone()),
(None, true) => None,
(None, false) => anyhow::bail!("--server needs an explicit scope: pass --channel <name> or --whisper"),
(Some(_), true) => anyhow::bail!("--channel and --whisper are mutually exclusive"),
};
config.overrides.retain(|o| !(o.server == *server && o.channel == scope_channel));
config.overrides.push(PermissionOverride {
server: server.clone(),
channel: scope_channel,
level,
});
} else if channel.is_none() && !whisper {
config.default_permission = level;
} else {
anyhow::bail!("--channel / --whisper require --server");
}
identity::save_config(&dir, &config)?;
println!("✓ permission updated (applies to newly started bridges; a live session changes levels with its `set_perm` tool)");
}
PermCommand::Show => {
let config = identity::load_config(&dir)?;
print_perm_table(&config);
}
}
Ok(())
}
fn print_perm_table(config: &identity::Config) {
println!("default: {}", level_token(config.default_permission));
for over in &config.overrides {
let scope = over.channel.clone().unwrap_or_else(|| "<whisper>".to_owned());
println!("{} {} -> {}", over.server, scope, level_token(over.level));
}
}
async fn run_status(explicit: Option<&PathBuf>) -> Void {
let dir = config_dir(explicit)?;
let config = identity::load_config(&dir)?;
if config.servers.is_empty() {
println!("no servers registered; run `conclave register --server <url> --username <you>`");
return Ok(());
}
let identity = load_identity(explicit)?;
let mut unreachable = 0_usize;
for reg in &config.servers {
match control::one_shot(®.url, &identity, &cli_session(), ProtocolMessage::Who { channel: None }).await {
Ok(ProtocolMessage::Presence { sessions, .. }) => {
println!("{}\t{}/{}\treachable ({} session(s) online)", reg.url, reg.username, reg.machine, sessions.len());
}
Ok(other) => {
println!("{}\t{}/{}\tunreachable: unexpected response {other:?}", reg.url, reg.username, reg.machine);
unreachable += 1;
}
Err(err) => {
println!("{}\t{}/{}\tunreachable: {err:#}", reg.url, reg.username, reg.machine);
unreachable += 1;
}
}
}
println!();
print_perm_table(&config);
if unreachable > 0 {
anyhow::bail!("{unreachable} server(s) unreachable");
}
Ok(())
}
async fn run_channel(explicit: Option<&PathBuf>, command: &ChannelCommand) -> Void {
match command {
ChannelCommand::Create { server, name, visibility } => {
let visibility = parse_visibility(visibility.as_deref())?;
admin_op(explicit, server, AdminOp::CreateChannel { name: name.clone(), visibility }).await
}
ChannelCommand::Delete { server, name } => admin_op(explicit, server, AdminOp::DeleteChannel { name: name.clone() }).await,
ChannelCommand::Rename { server, name, new_name } => {
admin_op(
explicit,
server,
AdminOp::RenameChannel {
name: name.clone(),
new_name: new_name.clone(),
},
)
.await
}
ChannelCommand::SetVisibility { server, name, visibility } => {
let visibility = visibility.parse().map_err(anyhow::Error::from)?;
admin_op(explicit, server, AdminOp::SetVisibility { name: name.clone(), visibility }).await
}
ChannelCommand::List { server } => print_response(control::one_shot(server, &load_identity(explicit)?, &cli_session(), ProtocolMessage::ListChannels).await?),
}
}
async fn run_acl(explicit: Option<&PathBuf>, command: &AclCommand) -> Void {
match command {
AclCommand::Add { server, channel, user } => {
admin_op(
explicit,
server,
AdminOp::AclAdd {
channel: channel.clone(),
user: user.clone(),
},
)
.await
}
AclCommand::Remove { server, channel, user } => {
admin_op(
explicit,
server,
AdminOp::AclRemove {
channel: channel.clone(),
user: user.clone(),
},
)
.await
}
AclCommand::List { server, channel } => admin_op(explicit, server, AdminOp::AclList { channel: channel.clone() }).await,
}
}
async fn run_invite(explicit: Option<&PathBuf>, command: &InviteCommand) -> Void {
match command {
InviteCommand::Create { server, channel, uses, expires_in } => {
let expires_in_secs = expires_in.as_deref().map(parse_duration_secs).transpose()?;
admin_op(
explicit,
server,
AdminOp::InviteCreate {
channel: channel.clone(),
uses: *uses,
expires_in_secs,
},
)
.await
}
InviteCommand::Revoke { server, token } => admin_op(explicit, server, AdminOp::InviteRevoke { token: token.clone() }).await,
InviteCommand::List { server, channel } => admin_op(explicit, server, AdminOp::InviteList { channel: channel.clone() }).await,
}
}
async fn run_user(explicit: Option<&PathBuf>, command: &UserCommand) -> Void {
match command {
UserCommand::List { server } => print_response(control::one_shot(server, &load_identity(explicit)?, &cli_session(), ProtocolMessage::ListUsers).await?),
UserCommand::Remove { server, username } => admin_op(explicit, server, AdminOp::UserRemove { username: username.clone() }).await,
}
}
fn run_skill(args: &SkillArgs) -> Void {
let content = skill::render(&render_command_reference());
match &args.command {
None | Some(SkillCommand::Show) => print!("{content}"),
Some(SkillCommand::Install { dir }) => {
let base = match dir {
Some(dir) => dir.clone(),
None => dirs::home_dir().context("could not determine the home directory")?.join(".claude").join("skills"),
};
let path = skill::install_path(&base);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("failed to create `{}`", parent.display()))?;
}
std::fs::write(&path, content).with_context(|| format!("failed to write `{}`", path.display()))?;
println!("✓ installed the conclave skill to {}", path.display());
}
}
Ok(())
}
async fn run_bridge(explicit: Option<&PathBuf>, args: &BridgeArgs) -> Void {
let dir = config_dir(explicit)?;
let identity = identity::load_identity(&dir)?;
let config = identity::load_config(&dir)?;
let session = match &args.session {
Some(session) => session.clone(),
None => identity::default_handle(&std::env::current_dir().context("failed to read the working directory")?),
};
conclavelib::bridge::run(conclavelib::bridge::BridgeSetup {
identity,
config,
session,
servers: args.servers.clone(),
})
.await
}
fn default_machine_name() -> String {
gethostname::gethostname().to_string_lossy().into_owned()
}
fn level_token(level: PermissionLevel) -> String {
format!("{level:?}").to_lowercase()
}
fn parse_visibility(value: Option<&str>) -> Res<Visibility> {
match value {
Some(value) => value.parse().map_err(anyhow::Error::from),
None => Ok(Visibility::Public),
}
}
fn parse_duration_secs(value: &str) -> Res<u64> {
let value = value.trim();
let (digits, mult) = match value.chars().last() {
Some('s') => (&value[..value.len() - 1], 1),
Some('m') => (&value[..value.len() - 1], 60),
Some('h') => (&value[..value.len() - 1], 3600),
Some('d') => (&value[..value.len() - 1], 86_400),
_ => (value, 1),
};
let count: u64 = digits.trim().parse().with_context(|| format!("invalid duration `{value}`"))?;
Ok(count * mult)
}
fn render_command_reference() -> String {
let mut out = String::new();
append_command_help(&mut out, &Cli::command(), "conclave");
out
}
fn append_command_help(out: &mut String, command: &clap::Command, path: &str) {
use std::fmt::Write as _;
let mut command = command.clone();
let help = command.render_long_help().to_string();
let _ = write!(out, "### `{path}`\n\n```\n{}\n```\n\n", help.trim_end());
let subcommands: Vec<clap::Command> = command.get_subcommands().cloned().collect();
for sub in &subcommands {
if sub.get_name() != "help" {
append_command_help(out, sub, &format!("{path} {}", sub.get_name()));
}
}
}
#[derive(Parser, Debug)]
#[command(name = "conclave", author, version, about, long_about = None, propagate_version = true)]
struct Cli {
#[command(subcommand)]
command: Command,
#[arg(long, global = true)]
config_dir: Option<PathBuf>,
#[arg(short, long, global = true)]
verbose: bool,
}
#[derive(Subcommand, Debug)]
enum Command {
Serve(ServeArgs),
Bridge(BridgeArgs),
Key,
Register(RegisterArgs),
Machine {
#[command(subcommand)]
command: MachineCommand,
},
Server {
#[command(subcommand)]
command: ServerCommand,
},
Join(JoinArgs),
Perm {
#[command(subcommand)]
command: PermCommand,
},
Channel {
#[command(subcommand)]
command: ChannelCommand,
},
Acl {
#[command(subcommand)]
command: AclCommand,
},
Invite {
#[command(subcommand)]
command: InviteCommand,
},
Status,
Send(SendArgs),
Tail(TailArgs),
Who(WhoArgs),
Kick(KickArgs),
Ban(BanArgs),
Unban(BanArgs),
Bans(BansArgs),
User {
#[command(subcommand)]
command: UserCommand,
},
Skill(SkillArgs),
Completions {
shell: clap_complete::Shell,
},
}
#[cfg(test)]
impl Command {
fn verb(&self) -> &'static str {
match self {
Command::Serve(_) => "serve",
Command::Bridge(_) => "bridge",
Command::Key => "key",
Command::Register(_) => "register",
Command::Machine { .. } => "machine",
Command::Server { .. } => "server",
Command::Join(_) => "join",
Command::Perm { .. } => "perm",
Command::Channel { .. } => "channel",
Command::Acl { .. } => "acl",
Command::Invite { .. } => "invite",
Command::Status => "status",
Command::Send(_) => "send",
Command::Tail(_) => "tail",
Command::Who(_) => "who",
Command::Kick(_) => "kick",
Command::Ban(_) => "ban",
Command::Unban(_) => "unban",
Command::Bans(_) => "bans",
Command::User { .. } => "user",
Command::Skill(_) => "skill",
Command::Completions { .. } => "completions",
}
}
}
fn parse_admin_binding(spec: &str) -> (String, Option<String>) {
match spec.split_once('=') {
Some((user, pubkey)) => (user.to_owned(), Some(pubkey.to_owned())),
None => (spec.to_owned(), None),
}
}
#[derive(Args, Debug)]
struct ServeArgs {
#[arg(long, env = "CONCLAVE_BIND", default_value = "0.0.0.0:4390")]
bind: String,
#[arg(long, env = "CONCLAVE_DATA_DIR")]
data_dir: Option<PathBuf>,
#[arg(long, conflicts_with = "data_dir")]
ephemeral: bool,
#[arg(long = "admin", env = "CONCLAVE_ADMINS", value_delimiter = ',', value_name = "USER[=PUBKEY]")]
admins: Vec<String>,
}
#[derive(Args, Debug)]
struct BridgeArgs {
#[arg(long = "server")]
servers: Vec<String>,
#[arg(long = "as")]
session: Option<String>,
}
#[derive(Args, Debug)]
struct RegisterArgs {
#[arg(long)]
server: String,
#[arg(long)]
username: String,
#[arg(long)]
machine: Option<String>,
}
#[derive(Subcommand, Debug)]
enum ServerCommand {
List,
Remove {
url: String,
},
}
#[derive(Subcommand, Debug)]
enum MachineCommand {
Add {
#[arg(long)]
server: String,
#[arg(long)]
name: String,
#[arg(long)]
pubkey: String,
},
List {
#[arg(long)]
server: String,
},
Remove {
#[arg(long)]
server: String,
name: String,
},
}
#[derive(Args, Debug)]
struct JoinArgs {
#[arg(long)]
server: String,
channel: String,
#[arg(long)]
token: Option<String>,
#[arg(long)]
perm: Option<String>,
}
#[derive(Subcommand, Debug)]
enum PermCommand {
Set {
level: String,
#[arg(long)]
server: Option<String>,
#[arg(long)]
channel: Option<String>,
#[arg(long)]
whisper: bool,
},
Show,
}
#[derive(Subcommand, Debug)]
enum ChannelCommand {
Create {
#[arg(long)]
server: String,
name: String,
#[arg(long)]
visibility: Option<String>,
},
Delete {
#[arg(long)]
server: String,
name: String,
},
Rename {
#[arg(long)]
server: String,
name: String,
new_name: String,
},
SetVisibility {
#[arg(long)]
server: String,
name: String,
visibility: String,
},
List {
#[arg(long)]
server: String,
},
}
#[derive(Subcommand, Debug)]
enum AclCommand {
Add {
#[arg(long)]
server: String,
#[arg(long)]
channel: String,
user: String,
},
Remove {
#[arg(long)]
server: String,
#[arg(long)]
channel: String,
user: String,
},
List {
#[arg(long)]
server: String,
#[arg(long)]
channel: String,
},
}
#[derive(Subcommand, Debug)]
enum InviteCommand {
Create {
#[arg(long)]
server: String,
#[arg(long)]
channel: String,
#[arg(long)]
uses: Option<u32>,
#[arg(long)]
expires_in: Option<String>,
},
Revoke {
#[arg(long)]
server: String,
token: String,
},
List {
#[arg(long)]
server: String,
#[arg(long)]
channel: String,
},
}
#[derive(Args, Debug)]
struct WhoArgs {
#[arg(long)]
server: String,
channel: Option<String>,
}
#[derive(Args, Debug)]
struct KickArgs {
#[arg(long)]
server: String,
#[arg(long)]
channel: String,
target: String,
}
#[derive(Args, Debug)]
struct BanArgs {
#[arg(long)]
server: String,
#[arg(long)]
channel: String,
user: String,
}
#[derive(Args, Debug)]
struct SendArgs {
#[arg(long)]
server: String,
#[arg(long)]
channel: String,
text: String,
}
#[derive(Args, Debug)]
struct TailArgs {
#[arg(long)]
server: String,
#[arg(long)]
channel: String,
#[arg(long = "as")]
session: Option<String>,
}
#[derive(Args, Debug)]
struct BansArgs {
#[arg(long)]
server: String,
#[arg(long)]
channel: String,
}
#[derive(Subcommand, Debug)]
enum UserCommand {
List {
#[arg(long)]
server: String,
},
Remove {
#[arg(long)]
server: String,
username: String,
},
}
#[derive(Args, Debug)]
struct SkillArgs {
#[command(subcommand)]
command: Option<SkillCommand>,
}
#[derive(Subcommand, Debug)]
enum SkillCommand {
Show,
Install {
#[arg(long)]
dir: Option<PathBuf>,
},
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn help_lists_the_core_subcommands() {
let help = Cli::command().render_long_help().to_string();
for verb in ["serve", "bridge", "register", "machine", "join", "perm", "key"] {
assert!(help.contains(verb), "help output is missing the `{verb}` subcommand");
}
}
#[test]
fn serve_parses_its_bind_flag() {
let cli = Cli::parse_from(["conclave", "serve", "--bind", "127.0.0.1:9000"]);
match cli.command {
Command::Serve(args) => assert_eq!(args.bind, "127.0.0.1:9000"),
other => panic!("expected `serve`, parsed {other:?}"),
}
}
#[test]
fn perm_scope_with_server_requires_an_unambiguous_scope() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
let ambiguous = PermCommand::Set {
level: "converse".to_owned(),
server: Some("wss://s1".to_owned()),
channel: None,
whisper: false,
};
let err = run_perm(Some(&dir), &ambiguous).expect_err("--server with no scope must be rejected");
assert!(err.to_string().contains("explicit scope"), "{err}");
let conflicting = PermCommand::Set {
level: "converse".to_owned(),
server: Some("wss://s1".to_owned()),
channel: Some("ops".to_owned()),
whisper: true,
};
let err = run_perm(Some(&dir), &conflicting).expect_err("--channel + --whisper must be rejected");
assert!(err.to_string().contains("mutually exclusive"), "{err}");
let explicit = PermCommand::Set {
level: "converse".to_owned(),
server: Some("wss://s1".to_owned()),
channel: None,
whisper: true,
};
run_perm(Some(&dir), &explicit).expect("an explicit whisper scope is accepted");
let config = identity::load_config(&dir).unwrap();
assert_eq!(config.overrides.len(), 1);
assert_eq!(config.overrides[0].channel, None);
}
#[test]
fn verbose_is_a_global_flag() {
let cli = Cli::parse_from(["conclave", "-v", "key"]);
assert!(cli.verbose);
assert_eq!(cli.command.verb(), "key");
}
#[test]
fn log_config_directive_precedence() {
assert_eq!(log_directive(false, None), "info");
assert_eq!(log_directive(true, None), "debug");
assert_eq!(log_directive(false, Some("warn")), "warn");
assert_eq!(log_directive(true, Some("trace")), "trace");
assert_eq!(log_directive(true, Some("")), "debug");
}
#[test]
fn config_dir_is_global_and_parses_after_the_subcommand() {
let cli = Cli::parse_from(["conclave", "register", "--server", "wss://s", "--username", "aaron", "--config-dir", "/tmp/x"]);
assert_eq!(cli.config_dir.as_deref(), Some(std::path::Path::new("/tmp/x")));
assert_eq!(cli.command.verb(), "register");
}
#[test]
fn skill_subcommand_parses_show_and_install() {
assert_eq!(Cli::parse_from(["conclave", "skill"]).command.verb(), "skill");
let install = Cli::parse_from(["conclave", "skill", "install", "--dir", "/tmp/skills"]);
match install.command {
Command::Skill(args) => assert!(matches!(args.command, Some(SkillCommand::Install { .. }))),
other => panic!("expected `skill`, parsed {other:?}"),
}
}
}