#[cfg(feature = "cli")]
use clap::{CommandFactory, Parser, Subcommand};
use rmcp::ServiceExt;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
#[cfg(feature = "cli")]
use vent_mcp::VentRequest;
use vent_mcp::{project_name_from_current_dir, LoadedConfig, VentMcpServer, VentService};
#[derive(Debug, Clone, PartialEq, Eq)]
enum RunMode {
Mcp,
#[cfg(feature = "cli")]
ListChannels,
#[cfg(feature = "cli")]
Vent {
message: String,
channel: Option<String>,
},
}
#[cfg(feature = "cli")]
#[derive(Debug, Parser)]
#[command(
name = "vent",
version,
about = "Run the vent-mcp STDIO server or send a vent from the command line.",
disable_help_subcommand = true
)]
struct CliArgs {
#[arg(long, help = "Run the MCP STDIO server.")]
mcp: bool,
#[arg(
short,
long,
value_name = "CHANNEL",
help = "Send the vent to a configured channel."
)]
channel: Option<String>,
#[command(subcommand)]
command: Option<CliCommand>,
#[arg(
value_name = "MESSAGE",
num_args = 0..,
trailing_var_arg = true,
allow_hyphen_values = true,
help = "Message to vent. Without arguments, the binary starts the MCP server."
)]
message: Vec<String>,
}
#[cfg(feature = "cli")]
#[derive(Debug, Subcommand)]
enum CliCommand {
#[command(about = "List configured vent channels.")]
List,
}
#[cfg(feature = "cli")]
impl CliArgs {
fn into_run_mode(self) -> RunMode {
let has_cli_args = self.mcp
|| self.channel.is_some()
|| self.command.is_some()
|| !self.message.is_empty();
if !has_cli_args {
return RunMode::Mcp;
}
if self.mcp {
if self.channel.is_some() || self.command.is_some() || !self.message.is_empty() {
CliArgs::command()
.error(
clap::error::ErrorKind::ArgumentConflict,
"--mcp cannot be combined with CLI arguments",
)
.exit();
}
return RunMode::Mcp;
}
match self.command {
Some(CliCommand::List) => {
if self.channel.is_some() || !self.message.is_empty() {
CliArgs::command()
.error(
clap::error::ErrorKind::ArgumentConflict,
"list cannot be combined with --channel or MESSAGE",
)
.exit();
}
RunMode::ListChannels
}
None => {
if self.message.is_empty() {
CliArgs::command()
.error(
clap::error::ErrorKind::MissingRequiredArgument,
"MESSAGE is required when using CLI options",
)
.exit();
}
RunMode::Vent {
message: self.message.join(" "),
channel: self.channel,
}
}
}
}
}
fn init_tracing() {
if let Ok(filter) = EnvFilter::try_from_default_env() {
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.init();
}
}
#[cfg(feature = "cli")]
fn parse_run_mode() -> RunMode {
CliArgs::parse().into_run_mode()
}
#[cfg(not(feature = "cli"))]
fn parse_run_mode() -> RunMode {
if std::env::args_os().len() > 1 {
eprintln!(
"CLI mode is disabled in this build. Rebuild with `--features cli` to use command arguments."
);
std::process::exit(2);
}
RunMode::Mcp
}
fn load_config_or_exit() -> LoadedConfig {
match LoadedConfig::load() {
Ok(loaded) => loaded,
Err(error) => {
eprintln!("Error loading config: {error}");
std::process::exit(1);
}
}
}
async fn shutdown_signal() {
let ctrl_c = tokio::signal::ctrl_c();
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let mut term = match signal(SignalKind::terminate()) {
Ok(signal) => signal,
Err(_) => {
let _ = ctrl_c.await;
return;
}
};
tokio::select! {
_ = ctrl_c => {}
_ = term.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = ctrl_c.await;
}
}
#[tokio::main]
async fn main() {
init_tracing();
let run_mode = parse_run_mode();
let loaded = load_config_or_exit();
match run_mode {
RunMode::Mcp => run_mcp_server(loaded).await,
#[cfg(feature = "cli")]
RunMode::ListChannels => list_channels(&loaded),
#[cfg(feature = "cli")]
RunMode::Vent { message, channel } => vent_from_cli(loaded, message, channel).await,
}
}
async fn run_mcp_server(loaded: LoadedConfig) {
let project = project_name_from_current_dir();
let service = VentService::new(loaded.into_config(), project);
let server = VentMcpServer::new(service);
tracing::info!("Starting vent-mcp server with stdio transport");
let transport = rmcp::transport::io::stdio();
match server.serve(transport).await {
Ok(service) => {
let cancel_token = service.cancellation_token();
let mut wait = Box::pin(service.waiting());
tokio::select! {
result = &mut wait => {
if let Err(error) = result {
eprintln!("Server error: {error}");
std::process::exit(1);
}
}
_ = shutdown_signal() => {
cancel_token.cancel();
if let Err(error) = wait.await {
eprintln!("Server error: {error}");
std::process::exit(1);
}
}
}
}
Err(error) => {
eprintln!("Failed to start server: {error}");
std::process::exit(1);
}
}
}
#[cfg(feature = "cli")]
fn list_channels(loaded: &LoadedConfig) {
let output = loaded.config().channel_list();
for channel in &output.channels {
if channel.name == output.default_channel {
println!("{} (default) - {}", channel.name, channel.description);
} else {
println!("{} - {}", channel.name, channel.description);
}
}
}
#[cfg(feature = "cli")]
async fn vent_from_cli(loaded: LoadedConfig, message: String, channel: Option<String>) {
let service = VentService::new(loaded.into_config(), project_name_from_current_dir());
let output = service.send(VentRequest { message, channel }).await;
if output.ok {
println!("vented {} to {}", output.event_id, output.channel);
return;
}
let error = output
.error
.unwrap_or_else(|| "delivery failed".to_string());
eprintln!("Error: {error}");
if output.event_id.is_empty() {
std::process::exit(2);
}
std::process::exit(1);
}
#[cfg(all(test, feature = "cli"))]
mod tests {
use clap::Parser;
use super::{CliArgs, RunMode};
fn parse(args: &[&str]) -> RunMode {
CliArgs::try_parse_from(args)
.expect("parse args")
.into_run_mode()
}
#[test]
fn no_args_run_mcp_server() {
assert_eq!(parse(&["vent"]), RunMode::Mcp);
}
#[test]
fn list_args_run_list_channels() {
assert_eq!(parse(&["vent", "list"]), RunMode::ListChannels);
}
#[test]
fn message_args_run_cli_vent() {
assert_eq!(
parse(&["vent", "--channel", "ux", "Workflow", "friction"]),
RunMode::Vent {
message: "Workflow friction".to_string(),
channel: Some("ux".to_string()),
}
);
}
}