1use std::io::Write;
2use std::path::Path;
3
4use anyhow::{Context, Result, bail};
5use clap::{Parser, Subcommand};
6
7use crate::agents;
8use crate::config::{self, Config};
9use crate::project::{self};
10use crate::shell_init;
11use crate::tmux;
12use crate::ui;
13use crate::worktree::{self, RemoveDecision};
14
15#[derive(Parser, Debug)]
16#[command(
17 name = "tmx",
18 about = "Project-aware tmux session manager",
19 long_about = "Scan project directories, show git status, and manage tmux sessions with project-type layouts.",
20 version
21)]
22struct Cli {
23 #[command(subcommand)]
24 command: Option<Command>,
25}
26
27#[derive(Subcommand, Debug)]
28enum Command {
29 #[command(
31 long_about = "Open a terminal dashboard listing every tmux pane and any Claude/Codex agent\nrunning inside it, plus codex-companion background jobs. Reads tmux + local\nstate (~/.claude/state/), no daemon required. Works the same in GUI tmux and\nover SSH.\n\nKeys:\n j/k or ↑/↓ navigate\n enter switch tmux client to the selected pane\n q / esc quit\n\nWith --json: skip the TUI, emit one snapshot to stdout as JSON and exit.\nUseful for agent-driven consumers (jq, scripts, other LLMs)."
32 )]
33 Agents {
34 #[arg(long)]
36 json: bool,
37 },
38
39 Cleanup,
41
42 List {
44 #[arg(long)]
46 json: bool,
47 },
48
49 Switch {
51 session: Option<String>,
53 },
54
55 Worktree {
57 #[command(subcommand)]
58 command: WorktreeCommand,
59 },
60
61 #[command(
63 name = "shell-init",
64 long_about = "Emit shell initialization code that defines a 'twt' function\nrouting to 'tmx worktree …'.\n\nAdd to your shell rc:\n\n eval \"$(tmx shell-init zsh)\"\n\nThen:\n\n twt feat-x # create worktree + spawn/switch tmux session (default)\n twt feat-x --keep-current # create only; cd into the new worktree (no tmux switch)\n twt feat-x -p # create only; print path (no cd, no tmux switch)\n twt rm [target] # remove a worktree (picker if no target)\n twt list # picker → cd into the selected worktree\n twt list <target> # cd into the named worktree (path or short branch)\n twt list --plain # dump all worktrees as plain text (no cd)\n\nSupported shells: zsh."
65 )]
66 ShellInit {
67 shell: String,
69 },
70}
71
72#[derive(Subcommand, Debug)]
73enum WorktreeCommand {
74 #[command(
76 long_about = "Create a sibling git worktree, optionally run a post-create command,\nand by default spawn a tmux session at the new worktree and switch to it.\n\nWorktree is placed next to the source repo (sibling), named via the\n'worktree.naming' template in ~/.config/tmx/config.toml\n(default: '{repo}-{branch}'). Slashes in branch names are replaced\nwith dashes for the directory name.\n\nWith --keep-current, no tmux session is created and the worktree path is\nprinted on stdout (useful for shell wrappers that cd into the new path)."
77 )]
78 Create {
79 branch: String,
81
82 #[arg(long = "keep-current")]
85 keep_current: bool,
86
87 #[arg(long = "from")]
89 from: Option<String>,
90 },
91
92 #[command(
94 long_about = "Print a worktree path on stdout, intended for shell wrappers (`cd \"$(tmx worktree list)\"`).\n\nWith no target, an interactive picker is shown on stderr (so stdout stays clean for `$(...)` capture).\nWith a target (path or short branch), the resolved path is printed directly.\nWith --plain, all worktrees are dumped as 'path branch [flags]' lines on stdout.\nWith --json, all worktrees (or just the resolved target) are emitted as a JSON array."
95 )]
96 List {
97 target: Option<String>,
99
100 #[arg(long = "plain", conflicts_with = "json")]
102 plain: bool,
103
104 #[arg(long)]
106 json: bool,
107 },
108
109 #[command(
111 long_about = "Remove a worktree by exact path or short branch name (e.g. 'feat-x').\nIf no target is provided, an interactive picker is shown.\n\nSafety rules (apply even with --force):\n - Locked worktrees are refused; run 'git worktree unlock' first.\n - The worktree containing the current working directory is refused.\n - The worktree containing the current tmux session is refused.\n\nWithout --force, any tmux session attached to the worktree blocks removal.\nWith --force, attached sessions are killed (by id) and 'git worktree remove --force'\nis used (covers dirty worktrees)."
112 )]
113 Rm {
114 target: Option<String>,
116
117 #[arg(short = 'f', long = "force")]
119 force: bool,
120 },
121}
122
123pub fn run() -> Result<()> {
124 config::ensure_config_dir();
125 let cli = Cli::parse();
126 match cli.command {
127 None => run_selector(),
128 Some(Command::Agents { json }) => run_agents(json),
129 Some(Command::Cleanup) => run_cleanup(),
130 Some(Command::List { json }) => run_list(json),
131 Some(Command::Switch { session }) => run_switch(session.as_deref()),
132 Some(Command::Worktree { command }) => match command {
133 WorktreeCommand::Create {
134 branch,
135 keep_current,
136 from,
137 } => run_worktree_create(&branch, keep_current, from.as_deref().unwrap_or("")),
138 WorktreeCommand::List {
139 target,
140 plain,
141 json,
142 } => run_worktree_list(target.as_deref(), plain, json),
143 WorktreeCommand::Rm { target, force } => run_worktree_rm(target.as_deref(), force),
144 },
145 Some(Command::ShellInit { shell }) => run_shell_init(&shell),
146 }
147}
148
149fn run_selector() -> Result<()> {
150 let cfg = Config::load();
151 let projects = project::scan_projects(&cfg);
152 if projects.is_empty() {
153 println!("No projects found. Configure roots in ~/.config/tmx/config.toml");
154 println!("Example: roots = [\"~/dev\"]");
155 return Ok(());
156 }
157
158 let selected = ui::run_project_selector(projects)?;
159 let Some(project) = selected else {
160 return Ok(());
161 };
162 ui::open_project(&project)
163}
164
165fn run_agents(json: bool) -> Result<()> {
166 if json {
167 let mut proc = agents::proc::ProcSnapshot::new();
168 proc.refresh();
169 let snapshot = agents::collector::collect(&proc);
170 let stdout = std::io::stdout();
171 let mut handle = stdout.lock();
172 serde_json::to_writer_pretty(&mut handle, &snapshot).context("serialise snapshot")?;
173 writeln!(handle).ok();
174 return Ok(());
175 }
176 agents::run()
177}
178
179fn run_cleanup() -> Result<()> {
180 let killed = tmux::cleanup_sessions().context("cleanup failed")?;
181 if killed.is_empty() {
182 println!("No unattached sessions to clean up.");
183 } else {
184 println!("Killed {} session(s): {}", killed.len(), killed.join(", "));
185 }
186 Ok(())
187}
188
189fn run_list(json: bool) -> Result<()> {
190 if json {
193 let sessions = tmux::list_sessions().unwrap_or_default();
194 let stdout = std::io::stdout();
195 let mut handle = stdout.lock();
196 serde_json::to_writer_pretty(&mut handle, &sessions).context("serialise sessions")?;
197 writeln!(handle).ok();
198 return Ok(());
199 }
200
201 let sessions = match tmux::list_sessions() {
202 Ok(s) => s,
203 Err(_) => {
204 println!("No tmux server running.");
205 return Ok(());
206 }
207 };
208 if sessions.is_empty() {
209 println!("No active sessions.");
210 return Ok(());
211 }
212 for s in sessions {
213 let attached = if s.attached { " (attached)" } else { "" };
214 println!(" {} — {} window(s){}", s.name, s.windows, attached);
215 }
216 Ok(())
217}
218
219fn run_switch(name: Option<&str>) -> Result<()> {
220 if let Some(name) = name {
221 if !tmux::session_exists(name) {
222 tmux::create_session(name, "").context("failed to create session")?;
223 }
224 return tmux::switch_session(name).context("failed to switch session");
225 }
226
227 let sessions = match tmux::list_sessions() {
228 Ok(s) if !s.is_empty() => s,
229 _ => {
230 println!("No active tmux sessions.");
231 return Ok(());
232 }
233 };
234
235 let Some(selected) = ui::run_session_switcher(sessions)? else {
236 return Ok(());
237 };
238 tmux::switch_session(&selected.name).context("failed to switch session")
239}
240
241fn run_worktree_create(branch: &str, keep_current: bool, from: &str) -> Result<()> {
242 let cfg = Config::load();
243 let res = worktree::create(
244 worktree::Options {
245 branch: branch.to_string(),
246 from: from.to_string(),
247 start_in: String::new(),
248 },
249 &cfg,
250 )?;
251
252 eprintln!("Created worktree: {}", res.worktree_path.display());
253 if !res.script_ran.is_empty() {
254 eprintln!("Ran post-create command: {}", res.script_ran);
255 }
256
257 if keep_current {
258 println!("{}", res.worktree_path.display());
260 return Ok(());
261 }
262
263 let session_name = tmux::safe_session_name(&res.repo_name, branch);
264 if !tmux::session_exists(&session_name) {
265 tmux::create_session(&session_name, &res.worktree_path.to_string_lossy())
266 .context("create tmux session")?;
267 }
268 tmux::switch_session(&session_name).context("switch tmux session")
269}
270
271fn run_worktree_list(target: Option<&str>, plain: bool, json: bool) -> Result<()> {
272 let cwd = std::env::current_dir().context("resolve cwd")?;
273 let entries = worktree::list_entries(&cwd)?;
274
275 if json {
279 let payload: Vec<&worktree::WorktreeEntry> = if let Some(t) = target {
280 vec![worktree::resolve_target_any(&entries, t)?]
281 } else {
282 entries.iter().collect()
283 };
284 let stdout = std::io::stdout();
285 let mut handle = stdout.lock();
286 serde_json::to_writer_pretty(&mut handle, &payload).context("serialise worktrees")?;
287 writeln!(handle).ok();
288 return Ok(());
289 }
290
291 if plain {
292 for e in &entries {
293 let branch = e
294 .branch
295 .as_deref()
296 .map(worktree::short_branch)
297 .unwrap_or("(detached)");
298 let mut flags: Vec<&str> = Vec::new();
299 if e.is_main {
300 flags.push("main");
301 }
302 if e.locked {
303 flags.push("locked");
304 }
305 if e.prunable {
306 flags.push("prunable");
307 }
308 let suffix = if flags.is_empty() {
309 String::new()
310 } else {
311 format!(" [{}]", flags.join(","))
312 };
313 println!("{}\t{}{}", e.path.display(), branch, suffix);
314 }
315 return Ok(());
316 }
317
318 if let Some(t) = target {
319 let entry = worktree::resolve_target_any(&entries, t)?;
321 println!("{}", entry.path.display());
322 return Ok(());
323 }
324
325 if entries.is_empty() {
326 return Ok(());
327 }
328
329 let Some(selected) = ui::run_worktree_picker(entries)? else {
330 return Ok(());
331 };
332 println!("{}", selected.path.display());
333 Ok(())
334}
335
336fn run_worktree_rm(target: Option<&str>, force: bool) -> Result<()> {
337 let cwd = std::env::current_dir().context("resolve cwd")?;
338 let entries = worktree::list_entries(&cwd)?;
339
340 let entry: worktree::WorktreeEntry = match target {
341 Some(t) => worktree::resolve_target(&entries, t)?.clone(),
342 None => {
343 let removable: Vec<worktree::WorktreeEntry> =
344 entries.iter().filter(|e| !e.is_main).cloned().collect();
345 if removable.is_empty() {
346 println!("No removable worktrees.");
347 return Ok(());
348 }
349 let Some(selected) = ui::run_worktree_picker(removable)? else {
350 return Ok(());
351 };
352 selected
353 }
354 };
355
356 rm_preflight_and_remove(&entry, force, &cwd)
357}
358
359fn rm_preflight_and_remove(entry: &worktree::WorktreeEntry, force: bool, cwd: &Path) -> Result<()> {
360 if worktree::remove_decision(entry) == RemoveDecision::LockedRefuse {
362 bail!(
363 "worktree is locked: {}\nrun `git worktree unlock {}` first.",
364 entry.path.display(),
365 entry.path.display()
366 );
367 }
368
369 let target_canon = entry.path.canonicalize().ok();
371 if target_canon.is_none() {
372 return worktree::remove(&entry.path, force);
373 }
374 let target_canon = target_canon.unwrap();
375
376 let cwd_canon = cwd.canonicalize().context("canonicalize cwd")?;
378 if is_inside(&cwd_canon, &target_canon) {
379 bail!(
380 "refusing to remove worktree containing the current working directory.\ncd out of {} first, then re-run.",
381 entry.path.display()
382 );
383 }
384
385 let in_tmux = std::env::var_os("TMUX").is_some();
387 let current_session = if in_tmux {
388 match tmux::current_session_id() {
389 Some(id) => Some(id),
390 None => bail!("cannot determine current tmux session id; refusing to proceed"),
391 }
392 } else {
393 None
394 };
395
396 let attached: Vec<worktree::AttachedSession> =
397 match worktree::sessions_attached_to(&target_canon) {
398 Ok(v) => v,
399 Err(e) => {
400 if in_tmux {
401 bail!(
402 "tmux query failed while inside a tmux session; refusing to proceed: {e}"
403 );
404 } else {
405 Vec::new()
406 }
407 }
408 };
409
410 if let Some(cur) = current_session.as_deref()
411 && attached.iter().any(|s| s.id == cur)
412 {
413 bail!(
414 "refusing to remove worktree containing the current tmux session.\nswitch to a different session first, then re-run."
415 );
416 }
417
418 if !attached.is_empty() {
419 if !force {
420 let names: Vec<String> = attached.iter().map(|s| s.name.clone()).collect();
421 bail!(
422 "worktree {} is in use by tmux session(s): {}\npass --force to kill them and proceed",
423 entry.path.display(),
424 names.join(", ")
425 );
426 }
427 for s in &attached {
428 tmux::kill_session_id(&s.id)
429 .with_context(|| format!("kill-session {} ({})", s.id, s.name))?;
430 eprintln!("killed tmux session: {} ({})", s.name, s.id);
431 }
432 }
433
434 worktree::remove(&entry.path, force)
435}
436
437fn is_inside(child: &Path, parent: &Path) -> bool {
438 if child == parent {
439 return true;
440 }
441 let c = child.to_string_lossy();
442 let p = parent.to_string_lossy();
443 let p = p.trim_end_matches('/');
444 if let Some(rest) = c.strip_prefix(p)
445 && rest.starts_with('/')
446 {
447 return true;
448 }
449 false
450}
451
452fn run_shell_init(shell: &str) -> Result<()> {
453 match shell_init::emit(shell) {
454 Ok(text) => {
455 print!("{text}");
456 Ok(())
457 }
458 Err(e) => bail!("{e}"),
459 }
460}