use clap::{CommandFactory, FromArgMatches, Parser};
use std::env;
mod app;
mod auto_title;
mod event_handler;
mod rpc;
mod state;
mod ui;
mod update;
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[arg(short, long)]
workspace: Option<String>,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(clap::Subcommand)]
enum Commands {
#[command(
verbatim_doc_comment,
long_about = "\
Call an RPC method on the running crmux instance
For notification methods, reads JSON params from stdin.
For request methods (get-*), sends a request and prints the JSON response.
Methods:
send-text Send text to a session pane (notification)
Params: {\"text\": \"...\", \"project\": \"...\", \"no_execute\": true, \"mode\": \"plan-mode|accept-edits\"}
get-pane-id Get the pane ID where crmux is running (request)
get-sessions Get all sessions as JSON (request)
Params: {\"project\": \"...\"}
get-plans Get all accumulated plans as JSON (request)
Params: {\"project\": \"...\"}
Examples:
echo '{\"text\": \"hello\"}' | crmux rpc send-text
crmux rpc get-sessions
crmux rpc get-plans
echo '{\"project\": \"myapp\"}' | crmux rpc get-plans"
)]
Rpc {
method: String,
},
#[command(hide = true)]
Notify {
event: String,
},
Focus,
#[command(
trailing_var_arg = true,
long_about = "\
Launch a Claude Code session in a new tmux window with a specified width.
All arguments except -x are passed through to the claude command.
Examples:
crmux claude
crmux claude --resume
crmux claude -x 120 -p \"fix the bug\"
echo 'hello' | crmux claude"
)]
Claude {
#[arg(short = 'x', default_value = "100")]
width: u16,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
Update {
#[arg(long)]
force: bool,
#[arg(long)]
check: bool,
},
}
fn main() {
let cli = Cli::from_arg_matches(
&Cli::command()
.after_help(ui::HELP_TEXT)
.get_matches(),
)
.expect("failed to parse CLI arguments");
match cli.command {
Some(Commands::Rpc { method }) => {
if let Err(e) = handle_rpc(&method) {
eprintln!("crmux rpc error: {e}");
std::process::exit(1);
}
}
Some(Commands::Focus) => {
if let Err(e) = handle_focus() {
eprintln!("crmux focus error: {e}");
std::process::exit(1);
}
}
Some(Commands::Claude { width, args }) => {
if let Err(e) = handle_claude(width, &args) {
eprintln!("crmux claude error: {e}");
std::process::exit(1);
}
}
Some(Commands::Notify { event }) => {
eprintln!("warning: 'crmux notify' is deprecated, use 'crmux rpc' instead");
if let Err(e) = handle_rpc(&event) {
eprintln!("crmux notify error: {e}");
std::process::exit(1);
}
}
Some(Commands::Update { force, check }) => {
handle_update(force, check);
}
None => {
if env::var("TMUX").is_err() {
eprintln!("crmux must be run inside tmux");
std::process::exit(1);
}
if let Err(e) = app::run(cli.workspace) {
eprintln!("crmux error: {e}");
std::process::exit(1);
}
}
}
}
fn handle_update(force: bool, check: bool) {
let current = env!("CARGO_PKG_VERSION");
println!("crmux v{current} - checking for updates...");
if check {
match update::fetch_latest_version() {
Ok(latest) => match update::check_update_needed(current, &latest) {
update::UpdateStatus::AlreadyLatest(v) => {
println!("Already up to date (latest: {v})");
}
update::UpdateStatus::UpdateAvailable(v) => {
println!("Update available: {v}");
println!("Run `crmux update` to install");
}
},
Err(e) => {
eprintln!("Failed to check for updates: {e}");
std::process::exit(1);
}
}
return;
}
if force {
match update::perform_update_force() {
Ok(status) => println!("{status}"),
Err(e) => {
eprintln!("Update failed: {e}");
std::process::exit(1);
}
}
} else {
match update::perform_update() {
Ok(status) => println!("{status}"),
Err(e) => {
eprintln!("Update failed: {e}");
std::process::exit(1);
}
}
}
}
fn handle_focus() -> Result<(), Box<dyn std::error::Error>> {
let result = rpc::send_request("get_pane_id", &serde_json::json!({}))?;
let pane_id = result
.as_str()
.ok_or("crmux is not running or pane ID is unavailable")?;
let status = std::process::Command::new("tmux")
.args(["switch-client", "-t", pane_id])
.status()?;
if !status.success() {
return Err(format!("tmux switch-client failed (exit {status})").into());
}
Ok(())
}
fn read_stdin_if_piped() -> Result<Option<String>, Box<dyn std::error::Error>> {
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return Ok(None);
}
let mut buf = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
Ok(Some(buf))
}
#[allow(clippy::literal_string_with_formatting_args)]
fn handle_claude(width: u16, args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let stdin_content = read_stdin_if_piped()?;
let claude_args = build_claude_args(args, stdin_content.as_deref());
let cwd = env::current_dir()?.to_string_lossy().to_string();
let mut tmux_args: Vec<String> = vec![
"new-window".into(),
"-d".into(),
"-c".into(),
cwd,
"-P".into(),
"-F".into(),
"#{window_id}".into(),
"--".into(),
"claude".into(),
];
tmux_args.extend(claude_args);
let output = std::process::Command::new("tmux")
.args(&tmux_args)
.output()?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().into());
}
let window_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
let output = std::process::Command::new("tmux")
.args([
"resize-window",
"-t",
&window_id,
"-x",
&width.to_string(),
])
.output()?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().into());
}
Ok(())
}
fn build_claude_args(args: &[String], stdin: Option<&str>) -> Vec<String> {
let mut result = args.to_vec();
if let Some(input) = stdin {
result.push(input.to_string());
}
result
}
fn handle_rpc(event: &str) -> Result<(), Box<dyn std::error::Error>> {
let method = event.replace('-', "_");
if method.starts_with("get_") {
let params = read_stdin_if_piped()?.map_or_else(
|| serde_json::json!({}),
|input| serde_json::from_str(input.trim()).unwrap_or_else(|_| serde_json::json!({})),
);
let result = rpc::send_request(&method, ¶ms)?;
println!("{}", serde_json::to_string_pretty(&result)?);
return Ok(());
}
let mut input = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut input)?;
let mut params: serde_json::Value =
serde_json::from_str(input.trim()).unwrap_or_else(|_| serde_json::json!({}));
if let Ok(pane_id) = env::var("TMUX_PANE") {
let resolved = std::process::Command::new("tmux")
.args([
"display-message",
"-p",
"-t",
&pane_id,
"#{session_name}:#{window_index}.#{pane_index}",
])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or(pane_id);
if let Some(obj) = params.as_object_mut() {
obj.insert("pane_id".to_string(), serde_json::Value::String(resolved));
}
}
rpc::send_notification(&method, ¶ms)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_claude_args_no_args_no_stdin() {
let result = build_claude_args(&[], None);
assert!(result.is_empty());
}
#[test]
fn build_claude_args_with_args() {
let args = vec!["--resume".to_string()];
assert_eq!(build_claude_args(&args, None), vec!["--resume"]);
}
#[test]
fn build_claude_args_stdin_no_args() {
let result = build_claude_args(&[], Some("hello"));
assert_eq!(result, vec!["hello"]);
}
#[test]
fn build_claude_args_stdin_appended_when_args_present() {
let args = vec!["--resume".to_string()];
let result = build_claude_args(&args, Some("hello"));
assert_eq!(result, vec!["--resume", "hello"]);
}
}