use std::ffi::OsString;
use std::path::PathBuf;
#[derive(Debug, Default, Eq, PartialEq)]
pub struct Options {
pub socket_path: Option<PathBuf>,
pub socket_name: Option<OsString>,
}
#[derive(Debug, Eq, PartialEq)]
pub enum Stop {
Help,
Version,
Misuse(String),
}
pub const HELP: &str = concat!(
"tmux-mcp — serve tmux over the Model Context Protocol on stdio\n",
"\n",
"USAGE:\n",
" tmux-mcp [OPTIONS]\n",
"\n",
"OPTIONS:\n",
" -S, --socket <PATH> Talk to the tmux server on this socket path\n",
" -L, --socket-name <NAME>\n",
" Talk to the tmux server with this socket name\n",
" -h, --help Print this help\n",
" -V, --version Print the version\n",
"\n",
"Without -S or -L the server uses the dedicated libtmux-mcp socket.\n",
"\n",
"ENVIRONMENT:\n",
" LIBTMUX_SOCKET_PATH Socket path, as -S; ignored when -S or -L is given\n",
" LIBTMUX_SOCKET Socket name, as -L; not together with\n",
" LIBTMUX_SOCKET_PATH\n",
" LIBTMUX_TMUX_CONFIG Absolute path of a tmux configuration file for a\n",
" daemon this starts\n",
" LIBTMUX_TOOLSETS Comma-separated toolsets: inspect, manage, execute,\n",
" teardown. Default: all four when this process\n",
" started the dedicated daemon, the first three\n",
" otherwise; empty offers none\n",
" LIBTMUX_TOOLS Comma-separated tools to add to the toolsets\n",
" LIBTMUX_EXCLUDE_TOOLS Comma-separated tools to remove; wins over both\n",
" LIBTMUX_ENVIRONMENT_VALUES\n",
" Comma-separated tmux environment variables whose\n",
" values tools may return. Default: none\n",
"\n",
"A tool left out by selection is neither listed nor callable. A malformed\n",
"setting stops startup.\n",
"\n",
"The protocol runs on stdout, so anything this prints for a person goes to\n",
"stderr, where an MCP client collects it as the server's log.\n",
);
impl Options {
pub fn parse<I>(arguments: I) -> Result<Self, Stop>
where
I: IntoIterator<Item = OsString>,
{
let mut options = Self::default();
let mut arguments = arguments.into_iter().peekable();
while let Some(argument) = arguments.next() {
let text = argument.to_string_lossy().into_owned();
let (flag, inline) = match text.split_once('=') {
Some((flag, value)) if flag.starts_with("--") => {
(flag.to_owned(), Some(OsString::from(value)))
}
_ => (text, None),
};
let mut take = |flag: &str| -> Result<OsString, Stop> {
inline.clone().map_or_else(
|| {
arguments
.next()
.ok_or_else(|| Stop::Misuse(format!("{flag} needs a value")))
},
Ok,
)
};
match flag.as_str() {
"-h" | "--help" => return Err(Stop::Help),
"-V" | "--version" => return Err(Stop::Version),
"-S" | "--socket" => {
options.socket_path = Some(PathBuf::from(take("--socket")?));
}
"-L" | "--socket-name" => {
options.socket_name = Some(take("--socket-name")?);
}
other => {
return Err(Stop::Misuse(format!("unrecognised argument {other}")));
}
}
}
if options.socket_path.is_some() && options.socket_name.is_some() {
return Err(Stop::Misuse(
"give either --socket or --socket-name, not both".to_owned(),
));
}
Ok(options)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(arguments: &[&str]) -> Result<Options, Stop> {
Options::parse(arguments.iter().map(OsString::from))
}
#[test]
fn no_arguments_is_no_opinion() {
assert_eq!(parse(&[]), Ok(Options::default()));
}
#[test]
fn a_socket_path_is_read_either_way_round() {
let separated = parse(&["-S", "/tmp/sock"]).expect("valid");
let inline = parse(&["--socket=/tmp/sock"]).expect("valid");
assert_eq!(
separated.socket_path.as_deref(),
Some(Path::new("/tmp/sock"))
);
assert_eq!(inline.socket_path, separated.socket_path);
}
#[test]
fn a_socket_name_mirrors_tmuxs_own_flag() {
let parsed = parse(&["-L", "work"]).expect("valid");
assert_eq!(parsed.socket_name.as_deref(), Some(OsStr::new("work")));
}
#[test]
fn the_two_socket_flags_are_exclusive() {
let refused = parse(&["-S", "/tmp/sock", "-L", "work"]).expect_err("refused");
assert!(matches!(refused, Stop::Misuse(reason) if reason.contains("not both")));
}
#[test]
fn a_flag_without_its_value_says_so() {
let refused = parse(&["--socket"]).expect_err("refused");
assert!(matches!(refused, Stop::Misuse(reason) if reason.contains("needs a value")));
}
#[test]
fn help_and_version_stop_before_anything_else() {
assert_eq!(parse(&["--help"]), Err(Stop::Help));
assert_eq!(parse(&["-h"]), Err(Stop::Help));
assert_eq!(parse(&["--version"]), Err(Stop::Version));
assert_eq!(parse(&["-V"]), Err(Stop::Version));
assert_eq!(parse(&["--help", "--nonsense"]), Err(Stop::Help));
}
#[test]
fn an_unknown_argument_is_named_back() {
let refused = parse(&["--colour=blue"]).expect_err("refused");
assert!(matches!(refused, Stop::Misuse(reason) if reason.contains("--colour")));
}
#[test]
fn the_help_text_names_every_flag() {
for flag in [
"-S",
"--socket",
"-L",
"--socket-name",
"-h",
"--help",
"-V",
"--version",
] {
assert!(HELP.contains(flag), "the help text should mention {flag}");
}
}
use std::ffi::OsStr;
use std::path::Path;
}