#![deny(warnings, clippy::all)]
mod claude_status;
mod completions;
mod config;
mod init;
mod primitives;
mod tool;
mod remote;
mod state;
mod switcher;
mod tmux;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use config::Config;
use tmux::Tmux;
#[derive(Parser)]
#[command(
name = "muxr",
version,
about = "Tmux session manager for AI coding workflows"
)]
pub(crate) struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(long)]
tool: Option<String>,
#[arg(long, env = "MUXR_TMUX_SERVER")]
server: Option<String>,
#[arg(num_args = 0..)]
args: Vec<String>,
}
#[derive(Subcommand)]
enum Commands {
Init,
Ls {
#[arg(long)]
active: bool,
},
Save,
Restore,
#[command(name = "tmux-status")]
TmuxStatus,
#[command(name = "claude-status")]
ClaudeStatus,
New {
#[arg(long)]
tool: Option<String>,
#[arg(num_args = 1..)]
args: Vec<String>,
},
Rename {
name: String,
},
Kill {
name: String,
},
Retire {
name: String,
},
Switch,
Completions {
shell: String,
},
#[command(external_subcommand)]
External(Vec<String>),
}
fn main() -> Result<()> {
let cli = Cli::parse();
let tmux = Tmux::new(cli.server);
match cli.command {
Some(Commands::Init) => init::init(),
Some(Commands::Ls { active }) => cmd_ls(&tmux, active),
Some(Commands::Save) => {
let config = Config::load()?;
state::SavedState::save(&config, &tmux)
}
Some(Commands::Restore) => {
let config = Config::load()?;
state::SavedState::restore(&tmux, &config)
}
Some(Commands::TmuxStatus) => cmd_tmux_status(&tmux),
Some(Commands::ClaudeStatus) => claude_status::run(&tmux),
Some(Commands::Switch) => cmd_switch(&tmux),
Some(Commands::New { tool, args }) => cmd_new(&tmux, &args, tool.as_deref()),
Some(Commands::Rename { name }) => cmd_rename(&tmux, &name, cli.tool.as_deref()),
Some(Commands::Kill { name }) => cmd_kill(&tmux, &name),
Some(Commands::Retire { name }) => cmd_retire(&tmux, &name),
Some(Commands::Completions { shell }) => completions::generate(&shell),
Some(Commands::External(args)) => {
let config = Config::load()?;
cmd_harness_dispatch(&tmux, &config, &args)
}
None => {
if cli.args.is_empty() {
cmd_control_plane(&tmux)
} else {
let first = &cli.args[0];
let config = Config::load().ok();
let is_harness = config
.as_ref()
.map(|c| c.tool_names().contains(&first.to_string()))
.unwrap_or(false);
if is_harness {
let config = config.unwrap();
cmd_harness_dispatch(&tmux, &config, &cli.args)
} else {
cmd_open(&tmux, &cli.args, cli.tool.as_deref())
}
}
}
}
}
fn cmd_control_plane(tmux: &Tmux) -> Result<()> {
let session = "muxr";
let home = dirs::home_dir().context("Could not determine home directory")?;
if tmux.session_exists(session) {
tmux.attach(session)?;
} else {
tmux.create_session(session, &home, "")?;
tmux.attach(session)?;
}
Ok(())
}
fn cmd_open(tmux: &Tmux, args: &[String], tool_override: Option<&str>) -> Result<()> {
let config = Config::load()?;
let name = &args[0];
if config.is_remote(name) {
return cmd_open_remote(tmux, &config, name, args);
}
if !config.harnesses.contains_key(name) {
let names = config.all_names().join(", ");
anyhow::bail!("Unknown harness or remote: {name}\nKnown: {names}");
}
let campaign = args.get(1).with_context(|| {
format!(
"Usage: muxr {name} <campaign> [<date>]\n\
Campaign is required. List campaigns: muxr {name} ls"
)
})?;
let date = args
.get(2)
.cloned()
.unwrap_or_else(primitives::today);
let _ = tool_override;
cmd_open_campaign(tmux, &config, name, campaign, &date)
}
fn cmd_open_campaign(
tmux: &Tmux,
config: &Config,
harness_name: &str,
campaign: &str,
date: &str,
) -> Result<()> {
let harness_dir = config.resolve_dir(harness_name)?;
let campaign_md_path = harness_dir
.join("campaigns")
.join(campaign)
.join("campaign.md");
if !campaign_md_path.is_file() {
primitives::scaffold_campaign_interactive(&harness_dir, campaign)?;
}
let campaign_md = primitives::campaign_file(&harness_dir, campaign)?;
let session_path =
primitives::resolve_or_scaffold_session(&harness_dir, campaign, date)?;
let session_basename = session_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(date);
let session_name = format!("{harness_name}/{campaign}/{session_basename}");
if tmux.session_exists(&session_name) {
eprintln!("Attaching to {session_name}");
tmux.attach(&session_name)?;
return Ok(());
}
let tool = config.resolve_tool(harness_name, None);
let tool_config = config.tool_for(&tool);
let harness = config.harnesses.get(harness_name);
let mut settings = harness
.map(|v| v.launch.clone())
.unwrap_or_default();
let (campaign_data, campaign_body) = primitives::load_campaign(&campaign_md)?;
let (session_data, session_body) = primitives::load_session(&session_path)?;
if session_data.campaign != campaign {
anyhow::bail!(
"Session file {} declares campaign '{}' but was opened as '{}'.",
session_path.display(),
session_data.campaign,
campaign
);
}
if !session_data.entrypoint.is_empty() {
eprintln!(" entrypoint: {}", session_data.entrypoint);
}
let composed = primitives::compose_prompt(campaign, &campaign_body, &session_body);
settings
.append_system_prompt
.get_or_insert_with(Vec::new)
.push(composed);
for path in &campaign_data.paths {
let expanded = primitives::expand_home(path);
if !settings.add_dirs.iter().any(|d| d == &expanded) {
settings.add_dirs.push(expanded);
}
}
let session_dir = harness_dir.clone();
config.run_pre_create_hooks(&session_dir);
let tool_cmd = match &tool_config {
Some(h) => {
h.launch_command_with_settings(Some(&session_name), None, None, &settings)
}
None => tool.clone(),
};
eprintln!(
"Creating {session_name} in {} ({})",
session_dir.display(),
tool
);
if !campaign_data.synthesist_trees.is_empty() {
eprintln!(
" synthesist trees: {}",
campaign_data.synthesist_trees.join(", ")
);
}
if !campaign_data.paths.is_empty() {
eprintln!(" paths: {} added as --add-dir", campaign_data.paths.len());
}
tmux.create_session(&session_name, &session_dir, &tool_cmd)?;
tmux.attach(&session_name)?;
Ok(())
}
fn cmd_open_remote(
tmux: &Tmux,
config: &Config,
remote_name: &str,
args: &[String],
) -> Result<()> {
let remote = config
.remote(remote_name)
.context("Remote not found in config")?;
let context = if args.len() >= 2 {
args[1..].join("/")
} else {
"default".to_string()
};
if context == "ls" {
return cmd_remote_ls(remote, remote_name);
}
let session = format!("{remote_name}/{context}");
let instance = remote.instance_name(&context);
if tmux.session_exists(&session) {
eprintln!("Attaching to {session} (remote)");
tmux.attach(&session)?;
} else {
if let Err(e) = remote::bootstrap_claude_config(remote, &instance) {
eprintln!(" Bootstrap warning: {e}");
}
let connect_cmd = remote::connect_command(remote, &instance, &context)?;
eprintln!(
"Creating {session} -> {instance} via {}",
remote.connect
);
let home = dirs::home_dir().context("No home directory")?;
tmux.create_session(&session, &home, &connect_cmd)?;
tmux.attach(&session)?;
}
Ok(())
}
fn cmd_remote_ls(remote: &config::Remote, remote_name: &str) -> Result<()> {
let instances = remote::list_instances(remote)?;
if instances.is_empty() {
println!("No running instances for {remote_name}");
return Ok(());
}
for instance in &instances {
println!(" {instance}:");
match remote::list_remote_sessions(remote, instance) {
Ok(sessions) if !sessions.is_empty() => {
for sname in sessions {
println!(" {remote_name}/{sname}");
}
}
_ => println!(" (no tmux sessions)"),
}
}
Ok(())
}
fn cmd_new(tmux: &Tmux, args: &[String], tool_override: Option<&str>) -> Result<()> {
let config = Config::load()?;
let name = &args[0];
let context = if args.len() >= 2 {
args[1..].join("/")
} else {
"default".to_string()
};
let session = format!("{name}/{context}");
if tmux.session_exists(&session) {
eprintln!("{session} already exists");
return Ok(());
}
if config.is_remote(name) {
let remote = config.remote(name).context("Remote not found")?;
let instance = remote.instance_name(&context);
if let Err(e) = remote::bootstrap_claude_config(remote, &instance) {
eprintln!(" Bootstrap warning: {e}");
}
let connect_cmd = remote::connect_command(remote, &instance, &context)?;
let home = dirs::home_dir().context("No home directory")?;
tmux.create_session(&session, &home, &connect_cmd)?;
eprintln!("Created {session} -> {instance} (remote)");
} else if config.harnesses.contains_key(name) {
let tool = config.resolve_tool(name, tool_override);
let tool_def = config.tool_for(&tool);
let harness = config.harnesses.get(name);
let dir = config.resolve_dir(name)?;
let session_dir = dir.clone();
config.run_pre_create_hooks(&session_dir);
let tool_cmd = match (&tool_def, harness) {
(Some(t), Some(h)) => {
t.launch_command_with_settings(Some(&session), None, None, &h.launch)
}
(Some(t), None) => t.launch_command(Some(&session), None, None),
_ => tool.clone(),
};
tmux.create_session(&session, &session_dir, &tool_cmd)?;
eprintln!("Created {session} ({tool})");
} else {
let names = config.all_names().join(", ");
anyhow::bail!("Unknown harness or remote: {name}\nKnown: {names}");
}
Ok(())
}
fn cmd_rename(tmux: &Tmux, name: &str, tool_override: Option<&str>) -> Result<()> {
let old_name = tmux.current_session().unwrap_or_default();
rename_session_by_name(tmux, &old_name, name, tool_override)
}
pub(crate) fn rename_session_by_name(
tmux: &Tmux,
old: &str,
new: &str,
tool_override: Option<&str>,
) -> Result<()> {
if new.is_empty() {
anyhow::bail!("New name cannot be empty");
}
if new == old {
return Ok(());
}
if tmux.session_exists(new) {
anyhow::bail!("Session '{new}' already exists");
}
let harness = old.split('/').next().unwrap_or("default");
tmux.rename_session(Some(old), new)?;
eprintln!("Renamed {old} -> {new}");
if let Ok(config) = Config::load() {
let tool = config.resolve_tool(harness, tool_override);
if let Some(harness) = config.tool_for(&tool)
&& let Some(cmd) = harness.build_rename_command(new)
{
let new_target = Tmux::target(new);
let _ = std::process::Command::new("tmux")
.args(["send-keys", "-t", &new_target, &cmd, "Enter"])
.status();
eprintln!("Sent rename to {tool}");
}
}
Ok(())
}
fn cmd_kill(tmux: &Tmux, name: &str) -> Result<()> {
let kill_one = |sname: &str| {
tmux.kill_session(sname).ok();
eprintln!("Killed {sname}");
};
if name == "all" {
let sessions = tmux.list_sessions()?;
for (sname, _) in &sessions {
kill_one(sname);
}
} else if tmux.session_exists(name) {
kill_one(name);
} else {
eprintln!("Session not found: {name}");
}
Ok(())
}
fn cmd_retire(tmux: &Tmux, name: &str) -> Result<()> {
let config = Config::load().ok();
let retire_one = |sname: &str| {
if let Some(ref cfg) = config {
let harness = sname.split('/').next().unwrap_or(sname);
let tool = cfg.resolve_tool(harness, None);
if let Some(harness) = cfg.tool_for(&tool)
&& state::has_harness_process(tmux, sname, &harness.bin)
{
let target = Tmux::target(sname);
let _ = std::process::Command::new("tmux")
.args(["send-keys", "-t", &target, "/exit", "Enter"])
.status();
let shell_pid = tmux.pane_pid(sname).ok().flatten();
let harness_pid = shell_pid.and_then(|sp| {
state::descendant_pids(sp)
.into_iter()
.find(|pid| harness_proc_match(*pid, &harness.bin))
});
if let Some(pid) = harness_pid {
wait_for_pid_exit(pid, 10);
}
}
}
tmux.kill_session(sname).ok();
eprintln!("Retired {sname}");
};
if name == "all" {
let sessions = tmux.list_sessions()?;
for (sname, _) in &sessions {
retire_one(sname);
}
} else if tmux.session_exists(name) {
retire_one(name);
} else {
eprintln!("Session not found: {name}");
return Ok(());
}
if let Some(ref cfg) = config
&& let Err(e) = state::SavedState::save(cfg, tmux)
{
eprintln!(" state.json refresh: {e}");
}
Ok(())
}
fn wait_for_pid_exit(pid: u32, timeout_secs: u32) {
use std::process::Stdio;
for _ in 0..timeout_secs * 10 {
let alive = std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !alive {
return;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
eprintln!(" process {pid} did not exit, sending SIGKILL");
let _ = std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.stderr(Stdio::null())
.status();
}
fn harness_proc_match(pid: u32, bin: &str) -> bool {
use std::process::Stdio;
let suffix = format!("/{bin}");
std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "args="])
.stderr(Stdio::null())
.output()
.map(|o| {
let args = String::from_utf8_lossy(&o.stdout);
args.split_whitespace()
.any(|tok| tok == bin || tok.ends_with(&suffix))
})
.unwrap_or(false)
}
fn cmd_ls(tmux: &Tmux, active_only: bool) -> Result<()> {
let config = Config::load().ok();
let sessions = tmux.list_sessions()?;
let tool_for = |harness: &str| -> Option<config::Tool> {
let cfg = config.as_ref()?;
let tool = cfg.resolve_tool(harness, None);
cfg.tool_for(&tool)
};
let mut shown = 0;
for (name, path) in &sessions {
let harness = name.split('/').next().unwrap_or(name);
if active_only {
if name == "muxr" {
continue;
}
let Some(harness) = tool_for(harness) else {
continue;
};
if !state::has_harness_process(tmux, name, &harness.bin) {
continue;
}
}
let is_remote = config
.as_ref()
.map(|c| c.is_remote(harness))
.unwrap_or(false);
if is_remote {
println!(" {name} (remote)");
} else {
println!(" {name} ({path})");
}
shown += 1;
}
if shown == 0 {
if active_only {
eprintln!("No active harness sessions.");
} else {
eprintln!("No active tmux sessions.");
}
}
Ok(())
}
fn cmd_switch(tmux: &Tmux) -> Result<()> {
match switcher::run(tmux)? {
switcher::Action::Switch(session) => tmux.attach(&session),
switcher::Action::Kill(session) => {
tmux.kill_session(&session)?;
eprintln!("Killed {session}");
cmd_switch(tmux)
}
switcher::Action::Rename(old, new) => {
if let Err(e) = rename_session_by_name(tmux, &old, &new, None) {
eprintln!("rename failed: {e}");
}
cmd_switch(tmux)
}
switcher::Action::None => Ok(()),
}
}
fn cmd_harness_dispatch(tmux: &Tmux, config: &Config, args: &[String]) -> Result<()> {
let harness_name = args.first().context("Missing harness name")?;
let harness = config
.tool_for(harness_name)
.with_context(|| format!("Unknown harness: {harness_name}"))?;
let sub = args.get(1).map(|s| s.as_str()).unwrap_or("status");
match sub {
"upgrade" => {
let model = find_flag_value(&args[2..], "--model");
tool::upgrade(tmux, config, harness_name, &harness, model.as_deref())
}
"model" => {
let model = args.get(2).map(|s| s.as_str());
tool::model_switch(tmux, config, harness_name, &harness, model)
}
"compact" => {
let threshold = find_flag_value(&args[2..], "--threshold")
.and_then(|v| v.parse::<u32>().ok());
tool::compact(tmux, config, harness_name, &harness, threshold)
}
"status" => tool::status(tmux, config, harness_name, &harness),
other => {
anyhow::bail!(
"Unknown {harness_name} subcommand: {other}\nAvailable: model, compact, upgrade, status"
)
}
}
}
fn find_flag_value(args: &[String], flag: &str) -> Option<String> {
args.iter()
.position(|a| a == flag)
.and_then(|i| args.get(i + 1))
.cloned()
}
fn cmd_tmux_status(tmux: &Tmux) -> Result<()> {
let session_name = tmux.display_message("#{session_name}")?;
let harness = session_name.split('/').next().unwrap_or(&session_name);
let config = Config::load().ok();
let color = config
.as_ref()
.map(|c| c.color_for(harness).to_string())
.unwrap_or_else(|| "#8a7f83".to_string());
print!("#[fg={color}]● #[fg=#E8DDD0]{session_name} #[fg=#3B3639]│ ");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_tool_uses_override() {
let config: Config = toml::from_str("[harnesses]").unwrap();
assert_eq!(config.resolve_tool("work", Some("opencode")), "opencode");
}
#[test]
fn resolve_tool_falls_back_to_config() {
let config: Config = toml::from_str("[harnesses]").unwrap();
assert_eq!(config.resolve_tool("work", None), "claude");
}
}