Skip to main content

tmx/
cli.rs

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    /// Show a live dashboard of agents running in tmux panes
30    #[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        /// Emit one snapshot to stdout as JSON instead of opening the TUI.
35        #[arg(long)]
36        json: bool,
37    },
38
39    /// Kill all unattached tmux sessions
40    Cleanup,
41
42    /// List all tmux sessions
43    List {
44        /// Emit the session list to stdout as JSON.
45        #[arg(long)]
46        json: bool,
47    },
48
49    /// Switch to a tmux session (interactive picker or by name)
50    Switch {
51        /// Optional session name. If provided, switch directly (creating if needed).
52        session: Option<String>,
53    },
54
55    /// Manage git worktrees for the current repo
56    Worktree {
57        #[command(subcommand)]
58        command: WorktreeCommand,
59    },
60
61    /// Emit shell integration code (defines the 'twt' wrapper)
62    #[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 name (currently only 'zsh')
68        shell: String,
69    },
70}
71
72#[derive(Subcommand, Debug)]
73enum WorktreeCommand {
74    /// Create a sibling git worktree for the current repo
75    #[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        /// New branch to create (also used to derive the directory name)
80        branch: String,
81
82        /// Do not spawn / switch to a tmux session for the new worktree;
83        /// just create it and print the path on stdout.
84        #[arg(long = "keep-current")]
85        keep_current: bool,
86
87        /// Base ref for the new branch (default: HEAD)
88        #[arg(long = "from")]
89        from: Option<String>,
90    },
91
92    /// List worktrees and print the chosen path on stdout
93    #[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        /// Worktree path or short branch name. Picker shown if omitted.
98        target: Option<String>,
99
100        /// Dump all worktrees as plain text instead of running the picker.
101        #[arg(long = "plain", conflicts_with = "json")]
102        plain: bool,
103
104        /// Emit worktrees to stdout as a JSON array.
105        #[arg(long)]
106        json: bool,
107    },
108
109    /// Remove a sibling git worktree (fuzzy picker if no target given)
110    #[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        /// Worktree path or short branch name. Picker shown if omitted.
115        target: Option<String>,
116
117        /// Kill conflicting sessions and pass --force to git.
118        #[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    // In JSON mode we always emit a (possibly empty) array — even when tmux
191    // isn't running. Consumers parse a single shape regardless of state.
192    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        // Print the path on stdout so callers can `cd "$(tmx worktree create … --keep-current)"`.
259        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    // `--json` always emits a JSON array, even with a target (1-element)
276    // and even when the repo has only its main worktree (single entry).
277    // Consumers can rely on the shape without branching on count.
278    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        // `list` is non-destructive, so the main worktree is a valid target.
320        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    // Step 1: locked preflight.
361    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    // Step 2: missing-target shortcut. Skip cwd / tmux checks and hand to git.
370    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    // Step 3: self-cwd guard.
377    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    // Step 4: self-session guard + conflict gate.
386    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}