nomoreide_cli/lib.rs
1//! The `nomoreide` command, as a library.
2//!
3//! This is a library so that the `nomoreide` crate — the name people type
4//! at `cargo install` — can be a real front door rather than a duplicate.
5//! It has nothing to offer as an API beyond [`run`]; the modules below stay
6//! private, and the binary in this crate is itself a three-line caller.
7//!
8//! The `nomoreide` binary's front door — the Rust half of `src/index.ts`.
9//!
10//! Dispatch order matters and mirrors the reference exactly. In particular
11//! `start` is overloaded: with no service name it is the MCP server (the shape
12//! an agent launches), and with one it is a service start. An agent config
13//! that says `nomoreide start` has to keep working.
14
15mod agents;
16mod attach;
17mod commands;
18mod daemon_cli;
19mod database;
20mod flags;
21mod git;
22mod linear;
23mod profile;
24mod remote;
25mod setup;
26mod tui;
27
28use std::process::ExitCode;
29
30use nomoreide_daemon_client::{resolve_daemon_port, RuntimePaths};
31
32/// Run the command line, returning the process exit code.
33///
34/// Async rather than blocking, and deliberately not wrapped in a runtime:
35/// each binary supplies its own `#[tokio::main]`, so neither has to know
36/// that the other exists.
37pub async fn run() -> ExitCode {
38 let args: Vec<String> = std::env::args().skip(1).collect();
39 // No deprecation notice here. The reference prints one on an interactive
40 // stderr telling the reader to install *this* binary; having installed it,
41 // there is nothing left to say.
42 let command = args.first().map(String::as_str).unwrap_or("mcp");
43 let paths = RuntimePaths::default();
44 let configured_port =
45 resolve_daemon_port(std::env::var("NOMOREIDE_DAEMON_PORT").ok().as_deref());
46
47 let code = match command {
48 "setup" => setup::run(&args[1..]),
49 // Bare `start` is the MCP server: it is what an agent spawns, and it
50 // predates the service-runtime meaning of the word.
51 "mcp" | "start" if args.len() <= 1 => match nomoreide_mcp::run_stdio().await {
52 Ok(()) => 0,
53 Err(error) => {
54 eprintln!("nomoreide: MCP server failed: {error}");
55 1
56 }
57 },
58 "daemon" => {
59 let rest = &args[1..];
60 let port = daemon_cli::port_flag(rest).unwrap_or(configured_port);
61 if rest.iter().any(|arg| !arg.starts_with("--")) {
62 daemon_cli::run(rest, &paths, port).await
63 } else {
64 return run_foreground_daemon(port).await;
65 }
66 }
67 "web" => web(&paths, configured_port).await,
68 "tui" => match tui::run(&paths, configured_port).await {
69 Ok(()) => 0,
70 Err(error) => {
71 if let Some(message) = error.message_text() {
72 eprintln!("{message}");
73 }
74 error.exit_code()
75 }
76 },
77 // Not a user-facing command: the daemon spawns this inside a terminal
78 // window it opened, handing it the socket and the one-shot token that
79 // authorise the attachment. Named with a `__` prefix for that reason,
80 // and absent from every usage string.
81 // The shared terminal launcher runs inside either this CLI daemon or
82 // the Tauri executable. Tauri historically used the flag spelling;
83 // accept both spellings here so the daemon's current executable can
84 // always attach the Terminal.app window it just opened.
85 command if is_terminal_attach_command(command) => {
86 let socket = args.get(1).map(String::as_str).unwrap_or("");
87 let token = args.get(2).map(String::as_str).unwrap_or("");
88 match nomoreide_core::external_terminal::run_attach(socket, token) {
89 Ok(()) => 0,
90 Err(error) => {
91 eprintln!("{error}");
92 1
93 }
94 }
95 }
96 _ => commands::run(&args, &paths, configured_port).await,
97 };
98 ExitCode::from(code)
99}
100
101fn is_terminal_attach_command(command: &str) -> bool {
102 matches!(command, "__terminal-attach" | "--terminal-attach")
103}
104
105/// `nomoreide daemon` with no subcommand: be the machine-global daemon.
106///
107/// Split out because it never returns normally — everything else in `main`
108/// produces an exit code, and this produces a process that lives until a
109/// signal or `/api/daemon/shutdown`.
110async fn run_foreground_daemon(port: u16) -> ExitCode {
111 let options = nomoreide_daemon::DaemonOptions {
112 port,
113 ..Default::default()
114 };
115 match nomoreide_daemon::run(options).await {
116 Ok(()) => ExitCode::SUCCESS,
117 Err(error) => {
118 eprintln!("nomoreide: daemon failed: {error:#}");
119 ExitCode::FAILURE
120 }
121 }
122}
123
124/// `nomoreide web [--port=N]` — make sure a daemon is up and say where it is.
125///
126/// Both lines go to **stderr**, including the URL. That looks wrong until you
127/// remember what calls this: a shell function that opens the browser, and a
128/// wrapper that wants the URL out of band from whatever the daemon then logs.
129/// The reference puts both there, so both stay there.
130async fn web(paths: &RuntimePaths, configured_port: u16) -> u8 {
131 let port = std::env::args()
132 .find_map(|arg| {
133 arg.strip_prefix("--port=")
134 .map(|value| resolve_daemon_port(Some(value)))
135 })
136 .unwrap_or(configured_port);
137 match nomoreide_daemon_client::ensure_daemon(paths, port, env!("CARGO_PKG_VERSION")).await {
138 Ok(daemon) => {
139 if let Some(warning) = &daemon.version_warning {
140 eprintln!("{warning}");
141 }
142 // Said out loud because it costs a second of startup. Silence would
143 // be a pause with no cause, which reads as the tool being slow.
144 if daemon.status == nomoreide_daemon_client::EnsureStatus::Upgraded {
145 eprintln!(
146 "NoMoreIDE daemon updated to v{}.",
147 env!("CARGO_PKG_VERSION")
148 );
149 }
150 eprintln!(
151 "NoMoreIDE web UI: http://127.0.0.1:{}",
152 daemon.endpoint.port()
153 );
154 0
155 }
156 Err(error) => {
157 eprintln!("{error}");
158 1
159 }
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::is_terminal_attach_command;
166
167 #[test]
168 fn terminal_attachment_accepts_the_shared_launchers_flag() {
169 assert!(is_terminal_attach_command("--terminal-attach"));
170 assert!(is_terminal_attach_command("__terminal-attach"));
171 assert!(!is_terminal_attach_command("terminal-attach"));
172 }
173}