clonetty 0.0.1

Spawn a new Alacritty window cloning an existing terminal's working directory and its nested-shell environment stack, so Ctrl-D peels back one shell layer at a time.
//! clonetty — spawn a new Alacritty window that clones an existing terminal's
//! working directory and, by default, its full environment as a *reconstructed
//! stack of nested shells*.
//!
//! Two sources of truth:
//!   * the *current* terminal (default) — walked from this process up `/proc`, or
//!   * a *targeted* terminal by PID (`--pid`) — walked from `/proc/<pid>/`.
//!
//! Alacritty is a single process that hosts many windows, so the Alacritty PID
//! does not identify one window; the meaningful per-window handle is the shell
//! PID running inside it. If you pass the Alacritty PID we descend to its child
//! shell (erroring, with a menu, when that is ambiguous). See `proc.rs`.
//!
//! ## What "cloning the environment" means here
//!
//! A plain env-var copy reproduces the *look* of a nested shell (e.g. an
//! `IN_NIX_SHELL` env, `SHLVL=5`) but only a single real shell process, so
//! Ctrl-D closes the window outright. By default clonetty instead reconstructs
//! the actual stack of shell **processes** from each ancestor's environment, so
//! Ctrl-D peels back one `nix-shell`/subshell layer at a time — exactly like the
//! source terminal. `--base` opts out, giving a clean fresh terminal (outermost
//! login-shell env only) at the same path. See `spawn.rs`.
//!
//! Capturing is read-only (`/proc` reads only): running clonetty never exits or
//! alters the shells in the source terminal.

mod proc;
mod shell_init;
mod spawn;
#[cfg(feature = "x11")]
mod x11;

use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand};
use std::fs;
use std::os::unix::fs::PermissionsExt;

use crate::proc::Terminal;

/// Clone an Alacritty terminal into a fresh window, reconstructing its nested
/// shells so Ctrl-D peels back one layer at a time.
#[derive(Debug, Parser)]
#[command(name = "clonetty", version, about)]
// With no subcommand, the flattened `Clone` args run (the default action);
// `subcommand_precedence_over_arg` makes a leading `shell-init` token dispatch to
// the subcommand rather than being swallowed by the trailing `COMMAND` positional.
#[command(args_conflicts_with_subcommands = true, subcommand_precedence_over_arg = true)]
struct Cli {
    #[command(subcommand)]
    command: Option<Command>,

    #[command(flatten)]
    clone: CloneArgs,
}

/// Auxiliary subcommands (the default, subcommand-less action is to clone).
#[derive(Debug, Subcommand)]
enum Command {
    /// Print a shell snippet (for `eval`) that records each interactive shell's
    /// live environment, so `--focused`/`--pid` can reconstruct an idle
    /// nix-shell's innermost layer. Add `eval "$(clonetty shell-init bash)"` to
    /// your shell init (or home-manager `programs.bash.initExtra`).
    ShellInit {
        /// Shell dialect to emit the snippet for.
        #[arg(value_enum, default_value_t = shell_init::Shell::Bash)]
        shell: shell_init::Shell,
    },
}

/// Options for the default clone action.
#[derive(Debug, Args)]
struct CloneArgs {
    /// Clone the terminal that owns this PID instead of the current one.
    ///
    /// Give the *innermost* shell PID inside the target window (e.g. `echo $$`
    /// there) to reconstruct nesting to full depth. If you give the alacritty
    /// process PID, its outermost child shell is used (unless several windows
    /// make it ambiguous, in which case they're listed for you to choose from).
    #[arg(short, long, value_name = "PID")]
    pid: Option<u32>,

    /// Clone the currently focused X11 window's terminal (Xorg only).
    ///
    /// Reads `_NET_ACTIVE_WINDOW` off the root window, then that window's
    /// `_NET_WM_PID`; equivalent to `--pid` of the focused window. Intended for a
    /// window-manager keybinding (i3, bspwm, ...) so no PID plumbing is needed —
    /// works even while a full-screen program (e.g. vim) holds the terminal.
    #[cfg(feature = "x11")]
    #[arg(short, long, conflicts_with = "pid")]
    focused: bool,

    /// Cull to a clean base terminal: only the outermost login-shell
    /// environment (no nix-shell/subshell nesting), opened at the same path.
    #[arg(short, long)]
    base: bool,

    /// Reuse the running alacritty process via `msg create-window`: faster and
    /// shares the process, but cannot set a custom environment or nesting.
    #[arg(short, long)]
    reuse: bool,

    /// Print what would run (command, env summary, and any generated rc files)
    /// without spawning anything or writing files.
    #[arg(short = 'n', long)]
    dry_run: bool,

    /// Run this command in the new window instead of an interactive shell. Runs
    /// in the innermost (leaf) environment; nesting/peeling does not apply.
    #[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "COMMAND")]
    command: Vec<String>,
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    // Auxiliary subcommands short-circuit before any terminal capture.
    if let Some(Command::ShellInit { shell }) = cli.command {
        print!("{}", shell_init::snippet(shell));
        return Ok(());
    }
    let cli = cli.clone;

    // 1. Determine the target: the focused X11 window (Xorg), an explicit
    //    `--pid`, or (default) the terminal we are already running in. `--focused`
    //    resolves to a PID and then flows through the same `from_pid` path.
    #[cfg(feature = "x11")]
    let target_pid = if cli.focused {
        Some(x11::focused_window_pid()?)
    } else {
        cli.pid
    };
    #[cfg(not(feature = "x11"))]
    let target_pid = cli.pid;

    // 2. Capture the source terminal (cwd + nested shell environments).
    let term = match target_pid {
        Some(pid) => Terminal::from_pid(pid)?,
        None => Terminal::current()?,
    };

    // Surface fidelity caveats up front.
    if !cli.reuse && !term.found_alacritty {
        eprintln!(
            "clonetty: warning: no alacritty ancestor found; the base environment \
             is best-effort and may still contain nix-shell/subshell variables."
        );
    }
    if cli.reuse && !cli.base && term.levels.len() > 1 {
        eprintln!(
            "clonetty: warning: --reuse cannot reconstruct nested shells; the new \
             window will not peel back on Ctrl-D."
        );
    }

    // 3. Plan the alacritty invocation (and any rc files it needs).
    let plan = spawn::build_plan(&term, &cli.command, cli.base, cli.reuse);

    // 4. Show it, or run it.
    if cli.dry_run {
        println!("{}", spawn::describe(&plan.cmd));
        println!("# {}", plan.summary.replace('\n', "\n# "));
        for (path, content) in &plan.rc_files {
            println!("\n# ===== {} =====", path.display());
            print!("{content}");
        }
        return Ok(());
    }

    // Write the synthesized rc files (private to us: they may hold secrets from
    // the copied environment). The new window's base shell removes them on exit.
    if let Some(dir) = &plan.tmp_dir {
        fs::create_dir_all(dir)
            .with_context(|| format!("creating temp dir {}", dir.display()))?;
        fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
            .with_context(|| format!("securing temp dir {}", dir.display()))?;
        for (path, content) in &plan.rc_files {
            fs::write(path, content)
                .with_context(|| format!("writing {}", path.display()))?;
            fs::set_permissions(path, fs::Permissions::from_mode(0o600))
                .with_context(|| format!("securing {}", path.display()))?;
        }
    }

    let mut cmd = plan.cmd;
    if cli.reuse {
        // A quick IPC round-trip that returns immediately; wait and check it.
        let status = cmd
            .status()
            .context("running `alacritty msg create-window` (is alacritty running?)")?;
        if !status.success() {
            anyhow::bail!("`alacritty msg create-window` failed with {status}");
        }
    } else {
        // Launch detached: spawn and return without waiting. The new alacritty
        // becomes an independent process (reparented to init when we exit).
        cmd.spawn()
            .context("launching alacritty (is it on your PATH?)")?;
    }

    Ok(())
}