Skip to main content

netget/cli/
mod.rs

1//! CLI module - handles command-line interface and application startup
2
3mod args;
4#[cfg(not(target_arch = "wasm32"))]
5pub(crate) mod banner;
6pub mod client_startup;
7pub mod crash_restore;
8pub mod easy_startup;
9pub mod input_state;
10pub mod management;
11pub mod model_select;
12#[cfg(not(target_arch = "wasm32"))]
13mod non_interactive;
14pub mod server_startup;
15#[cfg(not(target_arch = "wasm32"))]
16mod setup;
17mod startup_guard;
18mod tasks;
19#[cfg(not(target_arch = "wasm32"))]
20mod terminal_cleanup;
21pub mod theme;
22
23// Re-exported so MCP mode (`src/mcp_stdio`) can drive the scheduled-task ticker on the
24// same code path the TUI and non-interactive runner use, without exposing the whole
25// private `tasks` module.
26pub use tasks::{execute_due_tasks_owned_public, execute_due_tasks_public};
27
28use anyhow::Result;
29pub use args::{Args, MCP_TOKEN_ENV};
30use clap::Parser;
31use tracing::debug;
32
33use crate::events::EventHandler;
34use crate::llm::OllamaClient;
35use crate::settings::Settings;
36use crate::state::app_state::AppState;
37use crate::ui::App;
38
39/// Create the LLM client from CLI args, branching on --openai-url vs --ollama-url
40#[cfg(not(target_arch = "wasm32"))]
41pub fn create_llm_client(args: &Args) -> Result<OllamaClient> {
42    let mut client = create_llm_client_inner(args)?;
43    if let Some(secs) = args.llm_request_timeout {
44        client = client.with_request_timeout(std::time::Duration::from_secs(secs));
45    }
46    if let Some(max_tokens) = args.llm_max_tokens {
47        client = client.with_max_tokens(max_tokens);
48    }
49    client = client.with_sampling(crate::llm::SamplingOptions {
50        seed: args.llm_seed,
51        temperature: args.llm_temperature,
52    });
53    Ok(client)
54}
55
56#[cfg(not(target_arch = "wasm32"))]
57fn create_llm_client_inner(args: &Args) -> Result<OllamaClient> {
58    if let Some(ref openai_url) = args.openai_url {
59        let api_key = args.resolve_api_key().ok_or_else(|| {
60            anyhow::anyhow!(
61                "API key required for OpenAI-compatible endpoint.\n   Set NETGET_API_KEY (or OPENAI_API_KEY); --api-key also works but exposes the key in the process table."
62            )
63        })?;
64        if args.model.is_none() {
65            anyhow::bail!(
66                "--model is required when using --openai-url.\n   Example: --openai-url {} --model gpt-4o",
67                openai_url
68            );
69        }
70        Ok(OllamaClient::new_openai(openai_url, api_key))
71    } else {
72        let ollama_url = args
73            .ollama_url
74            .as_deref()
75            .unwrap_or("http://localhost:11434");
76        Ok(OllamaClient::new(ollama_url))
77    }
78}
79
80/// Main CLI entry point
81#[cfg(not(target_arch = "wasm32"))]
82pub async fn run() -> Result<()> {
83    let args = Args::parse();
84
85    // Handle --simple-list flag (list available simple protocols and exit)
86    if args.simple_list {
87        use crate::protocol::EASY_REGISTRY;
88        println!("Available simple protocols:");
89        println!();
90        let protocols = EASY_REGISTRY.get_all_names();
91        if protocols.is_empty() {
92            println!("  No simple protocols available (check compiled features)");
93        } else {
94            for name in protocols {
95                println!("  - {}", name);
96            }
97        }
98        println!();
99        println!("Usage: netget --simple <protocol>");
100        println!("Example: netget --simple http");
101        return Ok(());
102    }
103
104    // Handle --simple <protocol> flag (start simple protocol in non-interactive mode)
105    if let Some(ref protocol) = args.simple_protocol {
106        return run_simple_protocol(protocol, &args).await;
107    }
108
109    // Handle --client-list flag (list protocols available as clients and exit)
110    if args.client_list {
111        println!("Available client protocols:");
112        println!();
113        let mut protocols = crate::protocol::CLIENT_REGISTRY.list_protocols();
114        protocols.sort();
115        if protocols.is_empty() {
116            println!("  No client protocols available (check compiled features)");
117        } else {
118            for name in protocols {
119                println!("  - {}", name);
120            }
121        }
122        println!();
123        println!("Usage: netget --client <protocol> --connect <address> [instruction]");
124        println!("Example: netget --client redis --connect 127.0.0.1:6379");
125        return Ok(());
126    }
127
128    // Handle --docs flag (print all protocol documentation and exit)
129    if args.docs {
130        print!("{}", crate::protocol::render_all_protocol_docs());
131        return Ok(());
132    }
133
134    // Handle --client <protocol> flag (connect a client in non-interactive mode)
135    if let Some(ref protocol) = args.client_protocol {
136        return run_client(protocol, &args).await;
137    }
138
139    // Handle --server <protocol> flag (start a server directly, skipping the
140    // initial LLM call that would interpret the prompt into an open_server).
141    if let Some(ref protocol) = args.server_protocol {
142        return run_server_direct(protocol, &args).await;
143    }
144
145    // Handle --mcp-stdio flag (run as MCP STDIO server)
146    #[cfg(feature = "mcp-stdio")]
147    if args.mcp_stdio {
148        // Both MCP branches return before the init_logging() call further down,
149        // so without this no tracing subscriber is ever installed and every
150        // debug!/info!/error! in the process is silently discarded — half of the
151        // dual logging this codebase is built around, gone in the mode most
152        // often run. `false` selects the stderr writer, which is what MCP needs:
153        // stdout carries JSON-RPC and must not be written to.
154        setup::init_logging(&args, false)?;
155        let settings = Settings::load();
156        return crate::mcp_stdio::run_mcp_stdio(&args, settings).await;
157    }
158    #[cfg(not(feature = "mcp-stdio"))]
159    if args.mcp_stdio {
160        anyhow::bail!(
161            "MCP STDIO mode requires the 'mcp-stdio' feature.\n\
162             Build with: cargo build --features mcp-stdio"
163        );
164    }
165
166    // Handle --mcp-http flag (run as MCP HTTP/SSE server)
167    #[cfg(feature = "mcp-http")]
168    if let Some(port) = args.mcp_http {
169        // See the mcp-stdio branch above: this path also returns before the
170        // init_logging() call further down.
171        setup::init_logging(&args, false)?;
172        let settings = Settings::load();
173        return crate::mcp_stdio::run_mcp_http(&args, settings, port).await;
174    }
175    #[cfg(not(feature = "mcp-http"))]
176    if args.mcp_http.is_some() {
177        anyhow::bail!(
178            "MCP HTTP mode requires the 'mcp-http' feature.\n\
179             Build with: cargo build --features mcp-http"
180        );
181    }
182
183    // Check for actions JSON first (--load flag or JSON input)
184    let actions_json = args.get_actions_json()?;
185
186    // Try to get prompt (this reads stdin if needed)
187    let prompt = args.get_prompt()?;
188
189    // Determine if we're in interactive mode
190    let is_interactive = prompt.is_none() && actions_json.is_none() && args.is_interactive();
191
192    // Setup logging based on mode
193    setup::init_logging(&args, is_interactive)?;
194
195    // Load settings
196    let settings = Settings::load();
197
198    // Decide on mode based on input type
199    if let Some(actions) = actions_json {
200        // Non-interactive mode - we have actions JSON to execute
201        non_interactive::run_with_actions(actions, &args, settings).await
202    } else if let Some(prompt) = prompt {
203        // Non-interactive mode - we have a prompt
204        non_interactive::run_non_interactive(prompt, &args, settings).await
205    } else if args.is_interactive() {
206        // Interactive TUI mode - no prompt and terminal is available
207        debug!("Entering interactive TUI mode");
208        debug!("Creating AppState...");
209        let base_url = args
210            .openai_url
211            .clone()
212            .or_else(|| args.ollama_url.clone())
213            .unwrap_or_else(|| "http://localhost:11434".to_string());
214        let state = AppState::new_with_options(args.include_disabled_protocols, base_url);
215        state.set_min_stability(args.parse_min_stability()?).await;
216        debug!("AppState created");
217
218        // Configure rate limiter from CLI args
219        debug!("Configuring rate limiter...");
220        let rate_limiter_config = args.build_rate_limiter_config();
221        state.configure_rate_limiter(rate_limiter_config).await?;
222        debug!("Rate limiter configured");
223
224        // Determine scripting mode with priority: CLI arg > saved setting > auto-detected
225        debug!("Parsing scripting mode...");
226        let mode_to_set = if let Some(mode) = args.parse_scripting_mode()? {
227            Some(mode)
228        } else {
229            settings.parse_scripting_mode()
230        };
231        debug!("Scripting mode to set: {:?}", mode_to_set);
232
233        if let Some(mode) = mode_to_set {
234            // Validate that the requested environment is available
235            debug!("Getting scripting environment for validation...");
236            let scripting_env = state.get_scripting_env().await;
237            debug!("Scripting environment retrieved");
238            let available = match mode {
239                crate::state::app_state::ScriptingMode::On => true, // LLM chooses runtime
240                crate::state::app_state::ScriptingMode::Off => true, // Always available
241                crate::state::app_state::ScriptingMode::Python => scripting_env.python.is_some(),
242                crate::state::app_state::ScriptingMode::JavaScript => {
243                    scripting_env.javascript.is_some()
244                }
245                crate::state::app_state::ScriptingMode::Go => scripting_env.go.is_some(),
246                crate::state::app_state::ScriptingMode::Perl => scripting_env.perl.is_some(),
247            };
248
249            if !available {
250                anyhow::bail!(
251                    "{} environment is not available on this system. Please install it or choose a different environment.",
252                    mode
253                );
254            }
255
256            state.set_selected_scripting_mode(mode).await;
257        }
258
259        // Determine theme: CLI arg > auto-detect > neutral fallback
260        debug!("Parsing theme argument: {}", args.theme);
261        let theme_option = theme::parse_theme(&args.theme)?;
262        debug!("Theme option parsed: {:?}", theme_option);
263        let theme = if let Some(t) = theme_option {
264            debug!("Using explicit theme: {:?}", t);
265            t
266        } else {
267            // Auto-detect
268            debug!("Auto-detecting theme...");
269            let detected = theme::detect_theme().unwrap_or(theme::Theme::Neutral);
270            debug!("Theme detected: {:?}", detected);
271            detected
272        };
273        debug!("Creating color palette from theme: {:?}", theme);
274        let color_palette = theme::ColorPalette::from_theme(theme);
275        debug!("Color palette created");
276
277        // Get system capabilities for UI display
278        debug!("Getting system capabilities...");
279        let system_capabilities = state.get_system_capabilities().await;
280        debug!("Creating App...");
281        let app = App::new(system_capabilities);
282
283        // Initialize LLM backend (OpenAI, Hybrid, or Ollama-only)
284        #[cfg(feature = "embedded-llm")]
285        let llm = {
286            if args.openai_url.is_some() {
287                debug!("Creating OpenAI-compatible client...");
288                create_llm_client(&args)?
289                    .with_mock_config_file(args.mock_config_file.clone())
290                    .with_app_state(state.clone())
291            } else {
292                // Check if user wants embedded LLM
293                let use_hybrid = args.use_embedded || args.embedded_model.is_some();
294
295                if use_hybrid {
296                    debug!("Creating HybridLLMManager...");
297                    let embedded_path = args
298                        .embedded_model
299                        .as_ref()
300                        .map(|p| p.display().to_string());
301                    let hybrid =
302                        crate::llm::HybridLLMManager::new(args.use_embedded, embedded_path).await?;
303
304                    if let Some(client) = hybrid.ollama_client().await {
305                        debug!("Using Ollama backend from HybridLLMManager");
306                        client
307                            .with_mock_config_file(args.mock_config_file.clone())
308                            .with_app_state(state.clone())
309                    } else {
310                        debug!("Using embedded backend - creating fallback OllamaClient");
311                        let ollama_url = args
312                            .ollama_url
313                            .as_deref()
314                            .unwrap_or("http://localhost:11434");
315                        OllamaClient::new(ollama_url)
316                            .with_mock_config_file(args.mock_config_file.clone())
317                            .with_app_state(state.clone())
318                    }
319                } else {
320                    debug!("Creating OllamaClient...");
321                    create_llm_client(&args)?
322                        .with_mock_config_file(args.mock_config_file.clone())
323                        .with_app_state(state.clone())
324                }
325            }
326        };
327
328        #[cfg(not(feature = "embedded-llm"))]
329        let llm = {
330            debug!("Creating LLM client...");
331            create_llm_client(&args)?
332                .with_mock_config_file(args.mock_config_file.clone())
333                .with_app_state(state.clone())
334        };
335
336        // Store the configured LLM client in state so spawned servers can use it
337        state.set_llm_client(llm.clone()).await;
338
339        debug!("Creating EventHandler...");
340        let event_handler = EventHandler::new(state.clone(), llm.clone());
341
342        // The dashboard manages the terminal itself (no init_terminal needed).
343        debug!("Entering dashboard TUI...");
344        crate::tui::run_dashboard(
345            state,
346            app,
347            event_handler,
348            llm,
349            settings,
350            &args,
351            color_palette,
352        )
353        .await
354    } else {
355        // No prompt and no terminal available
356        anyhow::bail!(
357            "Cannot start in interactive mode without a terminal.\n\
358             Please provide a prompt via arguments or stdin."
359        )
360    }
361}
362
363/// Connect a client in non-interactive mode (`--client <PROTOCOL> --connect <ADDR>`)
364///
365/// The deterministic counterpart to the `open_client` LLM action: starting a
366/// client from a shell or a test used to require a model round-trip just to
367/// decide to do the thing the caller already asked for. This routes straight to
368/// `client_startup::start_client_from_action`, the same function the MCP
369/// `start_client` tool and the actions-JSON loader use.
370///
371/// Model selection deliberately mirrors `run_with_actions` rather than
372/// `run_simple_protocol`: no `select_or_validate_model()` call, so a client
373/// whose behaviour is fully described by `--client-handlers` connects and runs
374/// without an LLM backend being reachable at all. A model is still configured
375/// when one was requested, for clients that fall back to the `instruction`.
376#[cfg(not(target_arch = "wasm32"))]
377async fn run_client(protocol: &str, args: &Args) -> Result<()> {
378    use std::sync::Arc;
379    use std::time::Duration;
380    use tokio::sync::Mutex;
381
382    // Setup logging (non-interactive mode)
383    setup::init_logging(args, false)?;
384    let limits = non_interactive::RunLimits::from_args(args);
385
386    let remote_addr = args.client_addr.clone().ok_or_else(|| {
387        anyhow::anyhow!(
388            "--client requires --connect <ADDRESS>\n   Example: netget --client {} --connect 127.0.0.1:6379",
389            protocol
390        )
391    })?;
392
393    // Resolve the protocol before doing any setup work, so an unknown name
394    // fails immediately with the registry's own diagnostic.
395    let canonical = client_startup::resolve_client_protocol(protocol).ok_or_else(|| {
396        let mut available = crate::protocol::CLIENT_REGISTRY.list_protocols();
397        available.sort();
398        anyhow::anyhow!(
399            "Unknown client protocol: {}\n   Available in this build: {}\n   (see --client-list)",
400            protocol,
401            if available.is_empty() {
402                "none".to_string()
403            } else {
404                available.join(", ")
405            }
406        )
407    })?;
408
409    let startup_params = args.parse_client_params()?;
410    let event_handlers = args.parse_client_handlers()?;
411    let instruction = args.client_instruction(&canonical, &remote_addr);
412
413    let settings = Settings::load();
414
415    // Create application state
416    let base_url = args
417        .openai_url
418        .clone()
419        .or_else(|| args.ollama_url.clone())
420        .unwrap_or_else(|| "http://localhost:11434".to_string());
421    let state = AppState::new_with_options(args.include_disabled_protocols, base_url);
422    state.set_min_stability(args.parse_min_stability()?).await;
423
424    state
425        .configure_rate_limiter(args.build_rate_limiter_config())
426        .await?;
427
428    if let Some(model) = args.model.clone().or_else(|| settings.model.clone()) {
429        state.set_ollama_model(Some(model)).await;
430    }
431
432    if let Some(mode) = args
433        .parse_scripting_mode()?
434        .or_else(|| settings.parse_scripting_mode())
435    {
436        state.set_selected_scripting_mode(mode).await;
437    }
438
439    if let Some(handler_mode) = args.parse_event_handler_mode()? {
440        state.set_event_handler_mode(handler_mode).await;
441    }
442
443    // ASK web-search mode has no way to prompt without a TUI
444    let mut web_search_mode = settings.get_web_search_mode();
445    if web_search_mode == crate::state::app_state::WebSearchMode::Ask {
446        web_search_mode = crate::state::app_state::WebSearchMode::Off;
447    }
448    state.set_web_search_mode(web_search_mode).await;
449
450    let llm = create_llm_client(args)?
451        .with_mock_config_file(args.mock_config_file.clone())
452        .with_app_state(state.clone());
453    state.set_llm_client(llm.clone()).await;
454
455    println!(
456        "[CLIENT] Connecting {} client to {}",
457        canonical, remote_addr
458    );
459
460    let client_id = client_startup::start_client_from_action(
461        &state,
462        &canonical,
463        &remote_addr,
464        instruction,
465        startup_params,
466        None, // initial_memory
467        event_handlers,
468        None, // scheduled_tasks
469        None, // feedback_instructions
470        llm,
471        None, // status_tx: direct --client mode, drain to tracing
472    )
473    .await?;
474
475    println!(
476        "[CLIENT] Client #{} ({}) connected to {}. Press Ctrl+C to stop.",
477        client_id.as_u32(),
478        canonical,
479        remote_addr
480    );
481
482    // Set up Ctrl+C handler
483    let shutdown = Arc::new(Mutex::new(false));
484    let shutdown_clone = shutdown.clone();
485    tokio::spawn(async move {
486        tokio::signal::ctrl_c().await.ok();
487        let mut shutdown = shutdown_clone.lock().await;
488        *shutdown = true;
489    });
490
491    // Run until interrupted or the client goes away
492    loop {
493        tokio::time::sleep(Duration::from_millis(100)).await;
494        if *shutdown.lock().await {
495            println!("\n[CLIENT] Shutting down...");
496            break;
497        }
498        if let Some(reason) = limits.reached(&state).await {
499            println!("[CLIENT] Stopping: {reason}.");
500            break;
501        }
502        match state.get_client(client_id).await {
503            Some(client) => {
504                if matches!(
505                    client.status,
506                    crate::state::client::ClientStatus::Disconnected
507                        | crate::state::client::ClientStatus::Error(_)
508                ) {
509                    println!(
510                        "[CLIENT] Client #{} is {:?}",
511                        client_id.as_u32(),
512                        client.status
513                    );
514                    break;
515                }
516            }
517            None => break,
518        }
519    }
520
521    println!("[CLIENT] Client stopped.");
522    Ok(())
523}
524
525/// Start a server of `protocol` directly (`--server <PROTOCOL>`), skipping the
526/// initial model call that would otherwise interpret the prompt into an
527/// `open_server` action.
528///
529/// The deterministic counterpart to `open_server`: the operator already told us
530/// the protocol and port, so there is nothing for the model to decide up front.
531/// The trailing prompt becomes the server's per-request instruction, used by the
532/// per-request LLM (the same instruction `open_server` would have carried). This
533/// routes straight to `server_startup::start_server_from_action`, the same
534/// function the MCP `start_server` tool and the actions-JSON loader use, then
535/// hands off to the shared non-interactive server loop.
536#[cfg(not(target_arch = "wasm32"))]
537async fn run_server_direct(protocol: &str, args: &Args) -> Result<()> {
538    use tokio::sync::mpsc;
539
540    setup::init_logging(args, false)?;
541    let limits = non_interactive::RunLimits::from_args(args);
542
543    // Resolve the protocol up front so an unknown/compiled-out name fails
544    // immediately with the registry's own diagnostic (before any setup work).
545    let canonical = crate::protocol::server_registry::registry()
546        .resolve(protocol)
547        .map(|p| p.protocol_name().to_string())
548        .map_err(|e| anyhow::anyhow!("{e}"))?;
549
550    // The stdio pipe-filter owns the process's real stdout for the model's
551    // payload, so ALL of this function's own diagnostics (including the model
552    // line below) must go to stderr to keep that stream clean for a downstream
553    // pipe. Known from `canonical` alone, before any output is produced.
554    let to_stderr = non_interactive::protocol_owns_stdout(&canonical);
555
556    let startup_params = args.parse_server_params()?;
557    let instruction = args.server_instruction(&canonical);
558    let settings = Settings::load();
559
560    let base_url = args
561        .openai_url
562        .clone()
563        .or_else(|| args.ollama_url.clone())
564        .unwrap_or_else(|| "http://localhost:11434".to_string());
565    let state = AppState::new_with_options(args.include_disabled_protocols, base_url);
566    state.set_min_stability(args.parse_min_stability()?).await;
567    state
568        .configure_rate_limiter(args.build_rate_limiter_config())
569        .await?;
570
571    // Select/validate the model exactly as run_non_interactive does. A --server
572    // needs the LLM to answer every request, so — unlike a scripted --client — an
573    // unavailable model must fail HERE with a clear message. With no --model we
574    // pick an available one from Ollama rather than sending an unvalidated name
575    // (a stale settings default like a model that was never pulled) that fails on
576    // the first request with an unhelpful "LLM failed to generate valid response".
577    let configured_model = args.model.clone().or_else(|| settings.model.clone());
578    let selected_model = if args.openai_url.is_some() {
579        configured_model
580            .ok_or_else(|| anyhow::anyhow!("--model is required when using --openai-url"))?
581    } else {
582        let ollama_url = args
583            .ollama_url
584            .as_deref()
585            .unwrap_or("http://localhost:11434");
586        crate::llm::select_or_validate_model(configured_model, false, ollama_url)
587            .await?
588            .ok_or_else(|| {
589                anyhow::anyhow!("No model available — pull one in Ollama or pass --model")
590            })?
591    };
592    non_interactive::emit_status_line(
593        &format!("[SERVER] Using model: {}", selected_model),
594        to_stderr,
595    );
596    state.set_ollama_model(Some(selected_model)).await;
597
598    if let Some(mode) = args
599        .parse_scripting_mode()?
600        .or_else(|| settings.parse_scripting_mode())
601    {
602        state.set_selected_scripting_mode(mode).await;
603    }
604    if let Some(handler_mode) = args.parse_event_handler_mode()? {
605        state.set_event_handler_mode(handler_mode).await;
606    }
607    let mut web_search_mode = settings.get_web_search_mode();
608    if web_search_mode == crate::state::app_state::WebSearchMode::Ask {
609        web_search_mode = crate::state::app_state::WebSearchMode::Off;
610    }
611    state.set_web_search_mode(web_search_mode).await;
612
613    let llm = create_llm_client(args)?
614        .with_mock_config_file(args.mock_config_file.clone())
615        .with_app_state(state.clone());
616    state.set_llm_client(llm.clone()).await;
617
618    // Status channel + a forwarder that prints server startup/lifecycle lines.
619    // `to_stderr` (computed above) routes them off stdout for the stdio filter,
620    // so a downstream pipe receives only the model's payload bytes.
621    let (status_tx, mut status_rx) = mpsc::unbounded_channel::<String>();
622    let _forwarder = tokio::spawn(async move {
623        while let Some(msg) = status_rx.recv().await {
624            if !msg.starts_with("__") {
625                let clean = msg
626                    .strip_prefix("[INFO] ")
627                    .or_else(|| msg.strip_prefix("[ERROR] "))
628                    .or_else(|| msg.strip_prefix("[WARN] "))
629                    .or_else(|| msg.strip_prefix("[DEBUG] "))
630                    .unwrap_or(&msg);
631                // Through the shared writer, which does not panic when the reader on the far
632                // end of the pipe has gone away. See `non_interactive::emit_status_line`.
633                non_interactive::emit_status_line(clean, to_stderr);
634            }
635        }
636    });
637    tokio::task::yield_now().await;
638
639    let server_id = crate::cli::server_startup::start_server_from_action(
640        &state,
641        None, // mac_address
642        None, // interface
643        None, // host (defaults to 127.0.0.1)
644        args.server_port,
645        &canonical,
646        false, // send_first
647        None,  // initial_memory
648        instruction,
649        startup_params,
650        None, // event_handlers
651        None, // scheduled_tasks
652        None, // feedback_instructions
653        status_tx,
654    )
655    .await?;
656
657    let started_line = format!(
658        "Server #{} ({}) started, skipping the initial model call. Press Ctrl+C to stop.",
659        server_id.as_u32(),
660        canonical
661    );
662    non_interactive::emit_status_line(&started_line, to_stderr);
663    non_interactive::emit_status_line("Waiting for connections...\n", to_stderr);
664
665    // Hand off to the shared non-interactive server loop (Ctrl+C + task ticker).
666    let (_srv_tx, srv_rx) = mpsc::unbounded_channel::<String>();
667    non_interactive::run_server(&state, llm, srv_rx, limits).await
668}
669
670/// Run a simple protocol in non-interactive mode
671#[cfg(not(target_arch = "wasm32"))]
672async fn run_simple_protocol(protocol: &str, args: &Args) -> Result<()> {
673    use crate::protocol::EASY_REGISTRY;
674    use std::sync::Arc;
675    use std::time::Duration;
676    use tokio::sync::Mutex;
677
678    // Setup logging (non-interactive mode)
679    setup::init_logging(args, false)?;
680
681    // Check if protocol exists
682    if EASY_REGISTRY.get_by_name(protocol).is_none() {
683        eprintln!("Error: Unknown simple protocol: {}", protocol);
684        eprintln!("Use --simple-list to see available protocols");
685        std::process::exit(1);
686    }
687
688    println!("[SIMPLE] Starting simple protocol: {}", protocol);
689
690    // Load settings
691    let settings = Settings::load();
692
693    // Create application state
694    let base_url = args
695        .openai_url
696        .clone()
697        .or_else(|| args.ollama_url.clone())
698        .unwrap_or_else(|| "http://localhost:11434".to_string());
699    let state = AppState::new_with_options(args.include_disabled_protocols, base_url);
700    state.set_min_stability(args.parse_min_stability()?).await;
701
702    // Configure rate limiter from CLI args
703    let rate_limiter_config = args.build_rate_limiter_config();
704    state.configure_rate_limiter(rate_limiter_config).await?;
705
706    // Determine configured model: args override settings
707    let configured_model = args.model.clone().or(settings.model.clone());
708
709    // Select or validate model
710    let selected_model = if args.openai_url.is_some() {
711        configured_model
712            .ok_or_else(|| anyhow::anyhow!("--model is required when using --openai-url"))?
713    } else {
714        let ollama_url_for_model = args
715            .ollama_url
716            .as_deref()
717            .unwrap_or("http://localhost:11434");
718        crate::llm::select_or_validate_model(configured_model, false, ollama_url_for_model)
719            .await?
720            .ok_or_else(|| anyhow::anyhow!("No model available"))?
721    };
722
723    println!("[SIMPLE] Using model: {}", selected_model);
724    state.set_ollama_model(Some(selected_model)).await;
725
726    // Create LLM client
727    let llm = create_llm_client(args)?
728        .with_mock_config_file(args.mock_config_file.clone())
729        .with_app_state(state.clone());
730
731    // Store the configured LLM client in state so spawned servers can use it
732    state.set_llm_client(llm.clone()).await;
733
734    // Start the easy protocol
735    let easy_id = easy_startup::start_easy_protocol(
736        protocol,
737        None, // user_instruction - could be extended via CLI later
738        None, // port - could be extended via CLI later
739        Arc::new(state.clone()),
740        Arc::new(llm.clone()),
741    )
742    .await?;
743
744    println!(
745        "[SIMPLE] Started {} (easy instance #{})",
746        protocol,
747        easy_id.as_u32()
748    );
749
750    // Get underlying server info
751    if let Some(server_id) = state.get_first_server_id().await {
752        if let Some(server) = state.get_server(server_id).await {
753            println!("[SIMPLE] Listening on port {}", server.port);
754        }
755        println!(
756            "[SIMPLE] Server #{} is running. Press Ctrl+C to stop.",
757            server_id.as_u32()
758        );
759    }
760
761    // Set up Ctrl+C handler
762    let shutdown = Arc::new(Mutex::new(false));
763    let shutdown_clone = shutdown.clone();
764    tokio::spawn(async move {
765        tokio::signal::ctrl_c().await.ok();
766        let mut shutdown = shutdown_clone.lock().await;
767        *shutdown = true;
768    });
769
770    // Main event loop - just wait for shutdown
771    loop {
772        tokio::time::sleep(Duration::from_millis(100)).await;
773        if *shutdown.lock().await {
774            println!("\n[SIMPLE] Shutting down...");
775            break;
776        }
777    }
778
779    println!("[SIMPLE] Server stopped.");
780    Ok(())
781}