Skip to main content

tmx/
cli.rs

1use std::path::Path;
2
3use anyhow::{Context, Result, bail};
4use clap::{Parser, Subcommand};
5
6use crate::agents;
7use crate::config::{self, Config};
8use crate::project::{self};
9use crate::shell_init;
10use crate::tmux;
11use crate::ui;
12use crate::worktree::{self, RemoveDecision};
13
14#[derive(Parser, Debug)]
15#[command(
16    name = "tmx",
17    about = "Project-aware tmux session manager",
18    long_about = "Scan project directories, show git status, and manage tmux sessions with project-type layouts.",
19    version
20)]
21struct Cli {
22    #[command(subcommand)]
23    command: Option<Command>,
24}
25
26#[derive(Subcommand, Debug)]
27enum Command {
28    /// Show a live dashboard of agents running in tmux panes
29    #[command(
30        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"
31    )]
32    Agents,
33
34    /// Kill all unattached tmux sessions
35    Cleanup,
36
37    /// List all tmux sessions
38    List,
39
40    /// Switch to a tmux session (interactive picker or by name)
41    Switch {
42        /// Optional session name. If provided, switch directly (creating if needed).
43        session: Option<String>,
44    },
45
46    /// Manage git worktrees for the current repo
47    Worktree {
48        #[command(subcommand)]
49        command: WorktreeCommand,
50    },
51
52    /// Emit shell integration code (defines the 'twt' wrapper)
53    #[command(
54        name = "shell-init",
55        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."
56    )]
57    ShellInit {
58        /// Shell name (currently only 'zsh')
59        shell: String,
60    },
61}
62
63#[derive(Subcommand, Debug)]
64enum WorktreeCommand {
65    /// Create a sibling git worktree for the current repo
66    #[command(
67        long_about = "Create a sibling git worktree, optionally run a post-create script,\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)."
68    )]
69    Create {
70        /// New branch to create (also used to derive the directory name)
71        branch: String,
72
73        /// Do not spawn / switch to a tmux session for the new worktree;
74        /// just create it and print the path on stdout.
75        #[arg(long = "keep-current")]
76        keep_current: bool,
77
78        /// Base ref for the new branch (default: HEAD)
79        #[arg(long = "from")]
80        from: Option<String>,
81    },
82
83    /// List worktrees and print the chosen path on stdout
84    #[command(
85        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."
86    )]
87    List {
88        /// Worktree path or short branch name. Picker shown if omitted.
89        target: Option<String>,
90
91        /// Dump all worktrees as plain text instead of running the picker.
92        #[arg(long = "plain")]
93        plain: bool,
94    },
95
96    /// Remove a sibling git worktree (fuzzy picker if no target given)
97    #[command(
98        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)."
99    )]
100    Rm {
101        /// Worktree path or short branch name. Picker shown if omitted.
102        target: Option<String>,
103
104        /// Kill conflicting sessions and pass --force to git.
105        #[arg(short = 'f', long = "force")]
106        force: bool,
107    },
108}
109
110pub fn run() -> Result<()> {
111    config::ensure_config_dir();
112    let cli = Cli::parse();
113    match cli.command {
114        None => run_selector(),
115        Some(Command::Agents) => agents::run(),
116        Some(Command::Cleanup) => run_cleanup(),
117        Some(Command::List) => run_list(),
118        Some(Command::Switch { session }) => run_switch(session.as_deref()),
119        Some(Command::Worktree { command }) => match command {
120            WorktreeCommand::Create {
121                branch,
122                keep_current,
123                from,
124            } => run_worktree_create(&branch, keep_current, from.as_deref().unwrap_or("")),
125            WorktreeCommand::List { target, plain } => run_worktree_list(target.as_deref(), plain),
126            WorktreeCommand::Rm { target, force } => run_worktree_rm(target.as_deref(), force),
127        },
128        Some(Command::ShellInit { shell }) => run_shell_init(&shell),
129    }
130}
131
132fn run_selector() -> Result<()> {
133    let cfg = Config::load();
134    let projects = project::scan_projects(&cfg);
135    if projects.is_empty() {
136        println!("No projects found. Configure roots in ~/.config/tmx/config.toml");
137        println!("Example: roots = [\"~/dev\"]");
138        return Ok(());
139    }
140
141    let selected = ui::run_project_selector(projects)?;
142    let Some(project) = selected else {
143        return Ok(());
144    };
145    ui::open_project(&project)
146}
147
148fn run_cleanup() -> Result<()> {
149    let killed = tmux::cleanup_sessions().context("cleanup failed")?;
150    if killed.is_empty() {
151        println!("No unattached sessions to clean up.");
152    } else {
153        println!("Killed {} session(s): {}", killed.len(), killed.join(", "));
154    }
155    Ok(())
156}
157
158fn run_list() -> Result<()> {
159    let sessions = match tmux::list_sessions() {
160        Ok(s) => s,
161        Err(_) => {
162            println!("No tmux server running.");
163            return Ok(());
164        }
165    };
166    if sessions.is_empty() {
167        println!("No active sessions.");
168        return Ok(());
169    }
170    for s in sessions {
171        let attached = if s.attached { " (attached)" } else { "" };
172        println!("  {} — {} window(s){}", s.name, s.windows, attached);
173    }
174    Ok(())
175}
176
177fn run_switch(name: Option<&str>) -> Result<()> {
178    if let Some(name) = name {
179        if !tmux::session_exists(name) {
180            tmux::create_session(name, "").context("failed to create session")?;
181        }
182        return tmux::switch_session(name).context("failed to switch session");
183    }
184
185    let sessions = match tmux::list_sessions() {
186        Ok(s) if !s.is_empty() => s,
187        _ => {
188            println!("No active tmux sessions.");
189            return Ok(());
190        }
191    };
192
193    let Some(selected) = ui::run_session_switcher(sessions)? else {
194        return Ok(());
195    };
196    tmux::switch_session(&selected.name).context("failed to switch session")
197}
198
199fn run_worktree_create(branch: &str, keep_current: bool, from: &str) -> Result<()> {
200    let cfg = Config::load();
201    let res = worktree::create(
202        worktree::Options {
203            branch: branch.to_string(),
204            from: from.to_string(),
205            start_in: String::new(),
206        },
207        &cfg,
208    )?;
209
210    eprintln!("Created worktree: {}", res.worktree_path.display());
211    if !res.script_ran.is_empty() {
212        eprintln!("Ran post-create script: {}", res.script_ran);
213    }
214
215    if keep_current {
216        // Print the path on stdout so callers can `cd "$(tmx worktree create … --keep-current)"`.
217        println!("{}", res.worktree_path.display());
218        return Ok(());
219    }
220
221    let session_name = tmux::safe_session_name(&res.repo_name, branch);
222    if !tmux::session_exists(&session_name) {
223        tmux::create_session(&session_name, &res.worktree_path.to_string_lossy())
224            .context("create tmux session")?;
225    }
226    tmux::switch_session(&session_name).context("switch tmux session")
227}
228
229fn run_worktree_list(target: Option<&str>, plain: bool) -> Result<()> {
230    let cwd = std::env::current_dir().context("resolve cwd")?;
231    let entries = worktree::list_entries(&cwd)?;
232
233    if plain {
234        for e in &entries {
235            let branch = e
236                .branch
237                .as_deref()
238                .map(worktree::short_branch)
239                .unwrap_or("(detached)");
240            let mut flags: Vec<&str> = Vec::new();
241            if e.is_main {
242                flags.push("main");
243            }
244            if e.locked {
245                flags.push("locked");
246            }
247            if e.prunable {
248                flags.push("prunable");
249            }
250            let suffix = if flags.is_empty() {
251                String::new()
252            } else {
253                format!("  [{}]", flags.join(","))
254            };
255            println!("{}\t{}{}", e.path.display(), branch, suffix);
256        }
257        return Ok(());
258    }
259
260    if let Some(t) = target {
261        // `list` is non-destructive, so the main worktree is a valid target.
262        let entry = worktree::resolve_target_any(&entries, t)?;
263        println!("{}", entry.path.display());
264        return Ok(());
265    }
266
267    if entries.is_empty() {
268        return Ok(());
269    }
270
271    let Some(selected) = ui::run_worktree_picker(entries)? else {
272        return Ok(());
273    };
274    println!("{}", selected.path.display());
275    Ok(())
276}
277
278fn run_worktree_rm(target: Option<&str>, force: bool) -> Result<()> {
279    let cwd = std::env::current_dir().context("resolve cwd")?;
280    let entries = worktree::list_entries(&cwd)?;
281
282    let entry: worktree::WorktreeEntry = match target {
283        Some(t) => worktree::resolve_target(&entries, t)?.clone(),
284        None => {
285            let removable: Vec<worktree::WorktreeEntry> =
286                entries.iter().filter(|e| !e.is_main).cloned().collect();
287            if removable.is_empty() {
288                println!("No removable worktrees.");
289                return Ok(());
290            }
291            let Some(selected) = ui::run_worktree_picker(removable)? else {
292                return Ok(());
293            };
294            selected
295        }
296    };
297
298    rm_preflight_and_remove(&entry, force, &cwd)
299}
300
301fn rm_preflight_and_remove(entry: &worktree::WorktreeEntry, force: bool, cwd: &Path) -> Result<()> {
302    // Step 1: locked preflight.
303    if worktree::remove_decision(entry) == RemoveDecision::LockedRefuse {
304        bail!(
305            "worktree is locked: {}\nrun `git worktree unlock {}` first.",
306            entry.path.display(),
307            entry.path.display()
308        );
309    }
310
311    // Step 2: missing-target shortcut. Skip cwd / tmux checks and hand to git.
312    let target_canon = entry.path.canonicalize().ok();
313    if target_canon.is_none() {
314        return worktree::remove(&entry.path, force);
315    }
316    let target_canon = target_canon.unwrap();
317
318    // Step 3: self-cwd guard.
319    let cwd_canon = cwd.canonicalize().context("canonicalize cwd")?;
320    if is_inside(&cwd_canon, &target_canon) {
321        bail!(
322            "refusing to remove worktree containing the current working directory.\ncd out of {} first, then re-run.",
323            entry.path.display()
324        );
325    }
326
327    // Step 4: self-session guard + conflict gate.
328    let in_tmux = std::env::var_os("TMUX").is_some();
329    let current_session = if in_tmux {
330        match tmux::current_session_id() {
331            Some(id) => Some(id),
332            None => bail!("cannot determine current tmux session id; refusing to proceed"),
333        }
334    } else {
335        None
336    };
337
338    let attached: Vec<worktree::AttachedSession> =
339        match worktree::sessions_attached_to(&target_canon) {
340            Ok(v) => v,
341            Err(e) => {
342                if in_tmux {
343                    bail!(
344                        "tmux query failed while inside a tmux session; refusing to proceed: {e}"
345                    );
346                } else {
347                    Vec::new()
348                }
349            }
350        };
351
352    if let Some(cur) = current_session.as_deref()
353        && attached.iter().any(|s| s.id == cur)
354    {
355        bail!(
356            "refusing to remove worktree containing the current tmux session.\nswitch to a different session first, then re-run."
357        );
358    }
359
360    if !attached.is_empty() {
361        if !force {
362            let names: Vec<String> = attached.iter().map(|s| s.name.clone()).collect();
363            bail!(
364                "worktree {} is in use by tmux session(s): {}\npass --force to kill them and proceed",
365                entry.path.display(),
366                names.join(", ")
367            );
368        }
369        for s in &attached {
370            tmux::kill_session_id(&s.id)
371                .with_context(|| format!("kill-session {} ({})", s.id, s.name))?;
372            eprintln!("killed tmux session: {} ({})", s.name, s.id);
373        }
374    }
375
376    worktree::remove(&entry.path, force)
377}
378
379fn is_inside(child: &Path, parent: &Path) -> bool {
380    if child == parent {
381        return true;
382    }
383    let c = child.to_string_lossy();
384    let p = parent.to_string_lossy();
385    let p = p.trim_end_matches('/');
386    if let Some(rest) = c.strip_prefix(p)
387        && rest.starts_with('/')
388    {
389        return true;
390    }
391    false
392}
393
394fn run_shell_init(shell: &str) -> Result<()> {
395    match shell_init::emit(shell) {
396        Ok(text) => {
397            print!("{text}");
398            Ok(())
399        }
400        Err(e) => bail!("{e}"),
401    }
402}