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 commands;
17mod daemon_cli;
18mod database;
19mod flags;
20mod git;
21mod profile;
22mod remote;
23mod setup;
24mod tui;
25
26use std::process::ExitCode;
27
28use nomoreide_daemon_client::{resolve_daemon_port, RuntimePaths};
29
30/// Run the command line, returning the process exit code.
31///
32/// Async rather than blocking, and deliberately not wrapped in a runtime:
33/// each binary supplies its own `#[tokio::main]`, so neither has to know
34/// that the other exists.
35pub async fn run() -> ExitCode {
36 let args: Vec<String> = std::env::args().skip(1).collect();
37 // No deprecation notice here. The reference prints one on an interactive
38 // stderr telling the reader to install *this* binary; having installed it,
39 // there is nothing left to say.
40 let command = args.first().map(String::as_str).unwrap_or("mcp");
41 let paths = RuntimePaths::default();
42 let configured_port =
43 resolve_daemon_port(std::env::var("NOMOREIDE_DAEMON_PORT").ok().as_deref());
44
45 let code = match command {
46 "setup" => setup::run(&args[1..]),
47 // Bare `start` is the MCP server: it is what an agent spawns, and it
48 // predates the service-runtime meaning of the word.
49 "mcp" | "start" if args.len() <= 1 => match nomoreide_mcp::run_stdio().await {
50 Ok(()) => 0,
51 Err(error) => {
52 eprintln!("nomoreide: MCP server failed: {error}");
53 1
54 }
55 },
56 "daemon" => {
57 let rest = &args[1..];
58 let port = daemon_cli::port_flag(rest).unwrap_or(configured_port);
59 if rest.iter().any(|arg| !arg.starts_with("--")) {
60 daemon_cli::run(rest, &paths, port).await
61 } else {
62 return run_foreground_daemon(port).await;
63 }
64 }
65 "web" => web(&paths, configured_port).await,
66 "tui" => match tui::run(&paths, configured_port).await {
67 Ok(()) => 0,
68 Err(error) => {
69 if let Some(message) = error.message_text() {
70 eprintln!("{message}");
71 }
72 error.exit_code()
73 }
74 },
75 // Not a user-facing command: the daemon spawns this inside a terminal
76 // window it opened, handing it the socket and the one-shot token that
77 // authorise the attachment. Named with a `__` prefix for that reason,
78 // and absent from every usage string.
79 "__terminal-attach" => {
80 let socket = args.get(1).map(String::as_str).unwrap_or("");
81 let token = args.get(2).map(String::as_str).unwrap_or("");
82 match nomoreide_core::external_terminal::run_attach(socket, token) {
83 Ok(()) => 0,
84 Err(error) => {
85 eprintln!("{error}");
86 1
87 }
88 }
89 }
90 _ => commands::run(&args, &paths, configured_port).await,
91 };
92 ExitCode::from(code)
93}
94
95/// `nomoreide daemon` with no subcommand: be the machine-global daemon.
96///
97/// Split out because it never returns normally — everything else in `main`
98/// produces an exit code, and this produces a process that lives until a
99/// signal or `/api/daemon/shutdown`.
100async fn run_foreground_daemon(port: u16) -> ExitCode {
101 let options = nomoreide_daemon::DaemonOptions {
102 port,
103 ..Default::default()
104 };
105 match nomoreide_daemon::run(options).await {
106 Ok(()) => ExitCode::SUCCESS,
107 Err(error) => {
108 eprintln!("nomoreide: daemon failed: {error:#}");
109 ExitCode::FAILURE
110 }
111 }
112}
113
114/// `nomoreide web [--port=N]` — make sure a daemon is up and say where it is.
115///
116/// Both lines go to **stderr**, including the URL. That looks wrong until you
117/// remember what calls this: a shell function that opens the browser, and a
118/// wrapper that wants the URL out of band from whatever the daemon then logs.
119/// The reference puts both there, so both stay there.
120async fn web(paths: &RuntimePaths, configured_port: u16) -> u8 {
121 let port = std::env::args()
122 .find_map(|arg| {
123 arg.strip_prefix("--port=")
124 .map(|value| resolve_daemon_port(Some(value)))
125 })
126 .unwrap_or(configured_port);
127 match nomoreide_daemon_client::ensure_daemon(paths, port, env!("CARGO_PKG_VERSION")).await {
128 Ok(daemon) => {
129 if let Some(warning) = &daemon.version_warning {
130 eprintln!("{warning}");
131 }
132 // Said out loud because it costs a second of startup. Silence would
133 // be a pause with no cause, which reads as the tool being slow.
134 if daemon.status == nomoreide_daemon_client::EnsureStatus::Upgraded {
135 eprintln!(
136 "NoMoreIDE daemon updated to v{}.",
137 env!("CARGO_PKG_VERSION")
138 );
139 }
140 eprintln!(
141 "NoMoreIDE web UI: http://127.0.0.1:{}",
142 daemon.endpoint.port()
143 );
144 0
145 }
146 Err(error) => {
147 eprintln!("{error}");
148 1
149 }
150 }
151}