nomoreide-cli 0.20.0

NoMoreIDE: an AI-native service workbench with a CLI, a TUI, a web dashboard and an MCP server.
Documentation
use crate::commands::{CliError, CliResult};
use nomoreide_daemon_client::RuntimePaths;

const USAGE: &str = "Usage: nomoreide attach [--session ID] [--daemon]\n\nOpens a managed shell in this terminal and adds it to the NoMoreIDE dock.\nPrefers the running desktop app; --daemon selects the standalone daemon.\nUse Bring back in the dock to detach here without stopping the shell.";

pub async fn run(args: &[String], paths: &RuntimePaths, port: u16) -> CliResult {
    if args == ["--help"] || args == ["-h"] {
        println!("{USAGE}");
        return Ok(());
    }
    #[cfg(unix)]
    {
        use nomoreide_core::terminal::attach::{self, AttachRequest};
        use std::io::IsTerminal;
        let (session_id, daemon) = parse_args(args)?;
        if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
            return Err(CliError::usage(
                "nomoreide attach requires an interactive terminal",
            ));
        }
        if std::env::var_os("NOMOREIDE_TERMINAL_SESSION").is_some() {
            return Err(CliError::usage(
                "This shell is already attached to NoMoreIDE",
            ));
        }
        let desktop = RuntimePaths::desktop();
        let desktop_socket = attach::socket_path(&desktop.state_dir);
        let target = if !daemon && std::os::unix::net::UnixStream::connect(&desktop_socket).is_ok()
        {
            desktop
        } else {
            nomoreide_daemon_client::DaemonClient::ensure(paths, port, env!("CARGO_PKG_VERSION"))
                .await
                .map_err(|error| CliError::failure(error.to_string()))?;
            paths.clone()
        };
        let standalone = target == *paths;
        let request = AttachRequest {
            cwd: std::env::current_dir()
                .map_err(|error| CliError::failure(error.to_string()))?
                .to_string_lossy()
                .into_owned(),
            session_id,
            shell: std::env::var("SHELL").ok(),
            path: std::env::var("PATH").ok(),
        };
        tokio::task::spawn_blocking(move || {
            let attachment = attach::request(&target.state_dir, &request).map_err(|error| {
                format!("Could not attach to NoMoreIDE: {error}. Make sure the running app or daemon has been updated and restarted.")
            })?;
            eprintln!("Attached to NoMoreIDE: {}", attachment.session.id);
            let result = nomoreide_core::external_terminal::run_attach(
                &attachment.socket_path,
                &attachment.token,
            );
            eprintln!(
                "\r\nDetached. Reconnect with: nomoreide attach --session {}{}",
                attachment.session.id,
                if standalone { " --daemon" } else { "" }
            );
            result
        })
        .await
        .map_err(|error| CliError::failure(error.to_string()))?
        .map_err(CliError::failure)
    }
    #[cfg(not(unix))]
    {
        let _ = (args, paths, port);
        Err(CliError::usage(
            "Terminal attachment currently requires macOS or Linux",
        ))
    }
}

fn parse_args(args: &[String]) -> Result<(Option<String>, bool), CliError> {
    let mut session = None;
    let mut daemon = false;
    let mut args = args.iter();
    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--daemon" if !daemon => daemon = true,
            "--session" if session.is_none() => {
                session = Some(
                    args.next()
                        .filter(|id| !id.is_empty() && !id.starts_with('-'))
                        .ok_or_else(|| CliError::usage(USAGE))?
                        .clone(),
                );
            }
            _ => return Err(CliError::usage(USAGE)),
        }
    }
    Ok((session, daemon))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn accepts_attach_and_explicit_reconnect() {
        assert!(matches!(parse_args(&[]), Ok((None, false))));
        let args = ["--session", "cli:123", "--daemon"].map(str::to_string);
        assert!(matches!(parse_args(&args), Ok((Some(id), true)) if id == "cli:123"));
    }

    #[test]
    fn rejects_missing_duplicate_and_unknown_arguments() {
        for args in [
            vec!["--session"],
            vec!["--session", "--daemon"],
            vec!["--daemon", "--daemon"],
            vec!["--bogus"],
        ] {
            assert!(parse_args(&args.into_iter().map(str::to_string).collect::<Vec<_>>()).is_err());
        }
    }
}