Skip to main content

netget/cli/
args.rs

1//! Command-line argument parsing
2
3use anyhow::Result;
4use clap::Parser;
5use std::io::{self, IsTerminal, Read};
6use tracing::Level;
7
8/// Get default log level based on build type
9/// Development builds (debug_assertions) default to "debug"
10/// Release builds default to "info"
11///
12/// Dev builds used to default to `trace`. TRACE is the level at which NetGet
13/// writes whole network payloads and whole LLM prompts/responses to
14/// `netget.log` - which is both a 481 MB-per-day disk problem and a disclosure
15/// one, since those payloads can carry credentials from the wire. DEBUG keeps
16/// the per-event summaries that make development logs useful and stops short of
17/// the payloads; `--log-level trace` still turns them back on for the sessions
18/// that genuinely need byte-level detail.
19fn default_log_level() -> String {
20    if cfg!(debug_assertions) {
21        "debug".to_string()
22    } else {
23        "info".to_string()
24    }
25}
26
27/// Piped stdin, read at most once per process.
28///
29/// `cli::run()` asks for the actions JSON and then for the prompt. Both used to
30/// call `io::stdin().read_to_string()`, so the *first* call drained stdin and
31/// the second saw EOF: a prompt piped in (`echo "listen on port 80" | netget`)
32/// was consumed by the actions-JSON check, discarded because it was not JSON,
33/// and the process then failed with "Cannot start in interactive mode without a
34/// terminal" - only trailing-argument prompts worked non-interactively, despite
35/// `cat prompt.txt | netget` being advertised in --help.
36///
37/// Reading is lazy as well as once: `--mcp`, `--mcp-http`, `--simple`,
38/// `--client` and `--server` all return before either accessor is called, so
39/// stdin is never touched in the modes that need it for their own protocol. An
40/// interactive terminal is left alone entirely.
41///
42/// It is also only consulted when stdin is genuinely a prompt/actions *source*:
43/// the two accessors below skip it whenever a trailing-argument prompt (or
44/// `--load`) is present, because then the prompt does not come from stdin and
45/// draining it is pure downside. That guard is what lets the stdio pipe-filter
46/// (`prog | netget "be a filter" | prog`) start: its stdin is an open,
47/// never-EOF pipe, and the blocking `read_to_string` below would otherwise hang
48/// the whole process before any server could start. `cat prompt.txt | netget`
49/// still works — there is no trailing prompt there, so stdin is the source and
50/// is read.
51fn piped_stdin() -> &'static str {
52    static PIPED: std::sync::OnceLock<String> = std::sync::OnceLock::new();
53    PIPED
54        .get_or_init(|| {
55            if io::stdin().is_terminal() {
56                return String::new();
57            }
58            let mut buffer = String::new();
59            if let Err(e) = io::stdin().read_to_string(&mut buffer) {
60                tracing::warn!("Failed to read stdin: {}", e);
61                return String::new();
62            }
63            buffer.trim().to_string()
64        })
65        .as_str()
66}
67
68/// Read the `{"actions": [...]}` file named by `--load`.
69///
70/// Deliberately synchronous. `get_actions_json()` is called from inside the
71/// `#[tokio::main]` runtime, and the previous implementation built a *second*
72/// runtime there and `block_on`-ed `save_load::load_actions()` — which panics
73/// ("Cannot start a runtime from within a runtime") on a thread that is already
74/// driving one. `--load` therefore never worked; reading the file with
75/// `std::fs` in the one place that needs it does.
76///
77/// The path is used as given, with `.netget` appended only as a fallback when
78/// the literal path does not exist, so `--load configs/http` and
79/// `--load configs/http.netget` both work and a path with a different
80/// extension is still honoured.
81fn load_actions_file(path: &str) -> Result<Vec<serde_json::Value>> {
82    use anyhow::Context;
83
84    let trimmed = path.trim();
85    let candidate = std::path::Path::new(trimmed);
86    let resolved: std::path::PathBuf = if candidate.exists() {
87        candidate.to_path_buf()
88    } else {
89        let with_ext = std::path::PathBuf::from(format!(
90            "{}{}",
91            trimmed,
92            crate::utils::save_load::NETGET_EXTENSION
93        ));
94        if with_ext.exists() {
95            with_ext
96        } else {
97            candidate.to_path_buf() // report the error against what the user typed
98        }
99    };
100
101    let content =
102        crate::utils::file_io::read_text(&resolved, crate::utils::save_load::MAX_SESSION_BYTES)
103            .with_context(|| format!("Failed to read --load file: {}", resolved.display()))?;
104
105    let parsed: serde_json::Value = serde_json::from_str(&content)
106        .with_context(|| format!("Failed to parse JSON from {}", resolved.display()))?;
107
108    let actions = parsed
109        .get("actions")
110        .and_then(|v| v.as_array())
111        .ok_or_else(|| {
112            anyhow::anyhow!(
113                "{} must contain {{\"actions\": [...]}} (as written by the /save command)",
114                resolved.display()
115            )
116        })?
117        .clone();
118
119    Ok(actions)
120}
121
122/// NetGet - LLM-Controlled Network Application
123#[derive(Parser, Debug)]
124#[clap(
125    author,
126    version,
127    about,
128    long_about = "NetGet - LLM-Controlled Network Application\n\n\
129                  NetGet is an AI-powered network tool where an LLM controls network protocols.\n\
130                  It can operate in interactive mode (with TUI) or non-interactive mode.",
131    after_help = "EXAMPLES:\n\
132                  \n\
133                  Interactive mode (TUI):\n\
134                      netget\n\
135                  \n\
136                  Non-interactive with prompt (no quotes needed):\n\
137                      netget listen on port 80 via http\n\
138                      netget \"listen on port 80 via http\"     # quoted version\n\
139                      cat prompt.txt | netget\n\
140                  \n\
141                  Specify model with prompt after --:\n\
142                      netget -m llama3.2:latest -- listen on port 80\n\
143                      netget --model deepseek-coder:latest show version\n\
144                  \n\
145                  Specify scripting environment:\n\
146                      netget -e python listen on port 80\n\
147                      netget --env javascript -- start http server\n\
148                      netget --env llm show version\n\
149                  \n\
150                  MCP over HTTP (loopback by default; any other bind address needs a token):\n\
151                      netget --mcp-http 8080\n\
152                      NETGET_MCP_TOKEN=$(openssl rand -hex 32) netget --mcp-http 8080 --listen-addr 0.0.0.0\n\
153                  \n\
154                  Connect a client without a model round-trip:\n\
155                      netget --client redis --connect 127.0.0.1:6379\n\
156                      netget --client tcp --connect 10.0.0.5:9000 log every banner you receive\n\
157                      netget --client-list",
158    trailing_var_arg = true,
159    allow_hyphen_values = true
160)]
161pub struct Args {
162    /// LLM model to use (e.g., "llama3.2:latest", "deepseek-coder:latest")
163    #[clap(short = 'm', long = "model", value_name = "MODEL")]
164    pub model: Option<String>,
165
166    /// Log level (off, error, warn, info, debug, trace)
167    /// Development builds default to 'debug', release builds default to 'info'
168    /// ('trace' additionally writes full network payloads and LLM prompts to netget.log)
169    #[clap(
170        short = 'l',
171        long = "log-level",
172        value_name = "LEVEL",
173        default_value_t = default_log_level()
174    )]
175    pub log_level: String,
176
177    /// Scripting environment to use (on, off, python, javascript, go, perl)
178    #[clap(
179        short = 'e',
180        long = "env",
181        value_name = "ENVIRONMENT",
182        help = "Scripting environment: on (LLM chooses runtime), off (LLM only mode), python (Python scripting), javascript (JavaScript scripting), go (Go scripting), perl (Perl scripting)"
183    )]
184    pub scripting_env: Option<String>,
185
186    /// Event handler mode to use (any, script, static, llm)
187    #[clap(
188        long = "handler",
189        value_name = "MODE",
190        help = "Event handler mode: any (LLM chooses handler types), script (force script handlers), static (force static responses), llm (force LLM handlers)"
191    )]
192    pub event_handler_mode: Option<String>,
193
194    /// Bind address for the MCP HTTP control endpoint (default: 127.0.0.1)
195    ///
196    /// Read only by `--mcp-http`. Protocol servers take their bind address from
197    /// each `start_server` / `open_server` call's `host`, not from this flag.
198    #[clap(
199        long = "listen-addr",
200        value_name = "ADDRESS",
201        help = "IP address the --mcp-http control endpoint binds (default 127.0.0.1). Protocol servers are NOT affected; they bind per start_server 'host'. A non-loopback address requires --mcp-token or NETGET_MCP_TOKEN."
202    )]
203    pub listen_addr: Option<String>,
204
205    /// Include disabled protocols (for testing honeypot-only protocols like IPSec, OpenVPN)
206    #[clap(
207        long = "include-disabled-protocols",
208        help = "Includes experimental protocols for testing purposes"
209    )]
210    pub include_disabled_protocols: bool,
211
212    /// Refuse to start any protocol below this maturity level
213    #[clap(
214        long = "min-stability",
215        value_name = "LEVEL",
216        help = "Refuse to start any protocol whose declared development state is below LEVEL (incomplete, experimental, beta, stable; case-insensitive). Also filters lower-maturity protocols out of the LLM's base-stack menu so the model is not offered a protocol the operator forbade. Default: no minimum (only Incomplete protocols are hidden from the model)."
217    )]
218    pub min_stability: Option<String>,
219
220    // Developer note, deliberately a `//` comment and not a doc comment: clap derive turns
221    // doc comments into `--help` output, and none of this belongs in front of an operator.
222    //
223    // **This field is now write-only, and that is the whole point.** Nothing reads
224    // `args.ollama_lock`. The plumbing it used to feed is gone: the `ollama_lock_enabled` field
225    // on `AppStateInner`, `AppState::get_ollama_lock_enabled()`, the second parameter of
226    // `AppState::new_with_options`, the `lock_enabled` argument threaded through
227    // `create_llm_client`, and `OllamaClient::new_with_options`, whose body was `Self::new(url)`
228    // under a comment claiming locking was "handled at a different layer". Nothing in `src/` was
229    // that layer and no `ollama.lock` was ever created, so every one of those hops carried a
230    // boolean that could not change any behaviour — while making the flag look implemented to
231    // anyone who followed it.
232    //
233    // The flag itself stays **accepted and inert** rather than deleted. Removing it turns
234    // `--ollama-lock` into a hard clap error, and it appears in scripts and in this repo's own
235    // history; breaking those buys nothing, since an ignored flag and an absent one differ only
236    // in whether the caller gets an error for asking. `tests/ollama_lock_is_a_noop_test.rs`
237    // pins that it is still parsed, still does nothing, and that no `ollama.lock` appears.
238    //
239    // Implementing it was considered and rejected: a real cross-process lock would serialise
240    // every e2e test, and `--llm-max-concurrent` (with `--llm-queue-timeout` and
241    // `--llm-max-queued`) already bounds LLM concurrency and is actually enforced.
242    /// DEPRECATED AND IGNORED. Accepted for compatibility only; it locks nothing.
243    #[clap(
244        long = "ollama-lock",
245        help = "DEPRECATED AND IGNORED. Accepted for compatibility only: nothing is locked, nothing is written to disk, and NetGet instances are not serialized against each other. It has never done any of those things. To bound LLM concurrency use --llm-max-concurrent (with --llm-queue-timeout and --llm-max-queued), which are enforced."
246    )]
247    pub ollama_lock: bool,
248
249    /// Ollama API base URL (default: http://localhost:11434)
250    #[clap(
251        long = "ollama-url",
252        value_name = "URL",
253        help = "Base URL for Ollama API (default: http://localhost:11434). Use this to point to a custom Ollama instance or mock server for testing.",
254        conflicts_with = "openai_url",
255        hide = true  // Hidden from help output - primarily for testing
256    )]
257    pub ollama_url: Option<String>,
258
259    /// OpenAI-compatible API base URL (e.g., "https://api.openai.com", "http://localhost:1234")
260    #[clap(
261        long = "openai-url",
262        value_name = "URL",
263        help = "Base URL for an OpenAI-compatible API endpoint (e.g., https://api.openai.com, http://localhost:1234 for vLLM/LM Studio). Requires --model and an API key in NETGET_API_KEY or OPENAI_API_KEY (or, less safely, --api-key).",
264        conflicts_with = "ollama_url"
265    )]
266    pub openai_url: Option<String>,
267
268    /// API key for OpenAI-compatible endpoint (prefer the environment variable)
269    #[clap(
270        long = "api-key",
271        value_name = "KEY",
272        help = "API key for the OpenAI-compatible endpoint. PREFER the NETGET_API_KEY (or OPENAI_API_KEY) environment variable: an argument is visible to every local user in the process table (`ps`), an environment variable is not. Passing this flag prints a warning."
273    )]
274    pub api_key: Option<String>,
275
276    /// Route all LLM calls to the calling MCP agent instead of a model (MCP mode only)
277    #[clap(
278        long = "llm-agent",
279        help = "Do not call any model. Queue every LLM request for the calling MCP agent (e.g. Claude Code) to answer via the get_next_llm_request / answer_llm_request tools. Only meaningful with --mcp / --mcp-http; mutually exclusive with --ollama-url / --openai-url.",
280        conflicts_with_all = ["ollama_url", "openai_url"]
281    )]
282    pub llm_agent: bool,
283
284    /// FIFO path for agent-queue push notifications
285    #[clap(
286        long = "llm-agent-pipe",
287        value_name = "PATH",
288        help = "Named pipe (FIFO) that receives the id of each newly queued LLM request, so the agent can block-read it to get woken instead of polling. Created if absent. Requires --llm-agent; the long-poll tool works without it.",
289        requires = "llm_agent"
290    )]
291    pub llm_agent_pipe: Option<std::path::PathBuf>,
292
293    /// Seconds a queued LLM request waits for the agent's answer before erroring
294    #[clap(
295        long = "llm-agent-timeout",
296        value_name = "SECONDS",
297        default_value = "300",
298        help = "How long (seconds) a queued LLM request waits for the agent to answer before the call errors and the connection resets to Idle. Requires --llm-agent.",
299        requires = "llm_agent"
300    )]
301    pub llm_agent_timeout: u64,
302
303    /// Wall-clock bound (seconds) on a single LLM backend call
304    #[clap(
305        long = "llm-request-timeout",
306        value_name = "SECONDS",
307        help = "Wall-clock bound (seconds) on a single LLM backend call before it is treated as a transport failure and retried. Default: 300. A locally served 31B reasoning model answering a full prompt with the default tool list was measured at ~79s idle, so the old 120s default failed whenever anything else shared the GPU. Lower it only if you would rather fail fast than wait."
308    )]
309    pub llm_request_timeout: Option<u64>,
310
311    /// Completion-token budget for a single LLM call
312    #[clap(
313        long = "llm-max-tokens",
314        value_name = "TOKENS",
315        help = "Completion tokens a single LLM call may produce before it is cut off. This is a runaway guard, not a working budget: the default (32768) sits far above any legitimate answer so it never truncates one, and --llm-request-timeout is the primary backstop. Lower it only to bound a model you expect to ramble."
316    )]
317    pub llm_max_tokens: Option<u32>,
318
319    /// Sampler seed sent to the LLM backend
320    #[clap(
321        long = "llm-seed",
322        value_name = "SEED",
323        help = "Sampler seed sent with every LLM request (Ollama `options.seed`, OpenAI `seed`). With a fixed seed an identical prompt draws the same tokens, which is what makes a real-model run repeatable; a prompt that differs (a new port, a new query id) still diverges. Unset, nothing is sent and the model's own default applies."
324    )]
325    pub llm_seed: Option<u64>,
326
327    /// Sampling temperature sent to the LLM backend
328    #[clap(
329        long = "llm-temperature",
330        value_name = "TEMP",
331        value_parser = parse_temperature,
332        help = "Sampling temperature sent with every LLM request (Ollama `options.temperature`, OpenAI `temperature`). 0 is greedy decoding. Unset, nothing is sent and the model's own default applies (0.8 for most Ollama Modelfiles)."
333    )]
334    pub llm_temperature: Option<f32>,
335
336    /// Path to embedded GGUF model file (enables embedded LLM inference)
337    #[cfg(feature = "embedded-llm")]
338    #[clap(
339        long = "embedded-model",
340        value_name = "PATH",
341        help = "Path to GGUF model file for embedded inference (e.g., mistral-7b.Q4_K_M.gguf). When specified, NetGet will use embedded llama.cpp instead of or alongside Ollama."
342    )]
343    pub embedded_model: Option<std::path::PathBuf>,
344
345    /// Force use of embedded LLM backend (skip Ollama)
346    #[cfg(feature = "embedded-llm")]
347    #[clap(
348        long = "use-embedded",
349        help = "Use embedded LLM backend exclusively, skipping Ollama health check. Requires --embedded-model to be set or configured in ~/.netget/config.toml"
350    )]
351    pub use_embedded: bool,
352
353    /// Terminal color theme (auto, light, dark, neutral)
354    #[clap(
355        long = "theme",
356        value_name = "THEME",
357        default_value = "auto",
358        help = "Color theme for TUI: auto (detect background), light (dark colors on light background), dark (bright colors on dark background), neutral (medium contrast for both)"
359    )]
360    pub theme: String,
361
362    /// Show the generated ASCII art banner on startup (off by default)
363    ///
364    /// `--suppress-art` is kept as a hidden no-op so existing scripts and the
365    /// test harness keep working now that suppressing is the default.
366    #[clap(
367        long = "show-art",
368        help = "Show the Ollama-generated ASCII art banner on startup (costs one model round trip)"
369    )]
370    pub show_art: bool,
371
372    #[clap(long = "suppress-art", hide = true)]
373    pub suppress_art: bool,
374
375    /// Maximum concurrent LLM requests (default: 1)
376    #[clap(
377        long = "llm-max-concurrent",
378        value_name = "NUM",
379        help = "Maximum number of LLM requests in flight at once. Default: 1, i.e. sequential processing - additional requests QUEUE and are served in turn (bounded by --llm-queue-timeout and --llm-max-queued), they are not dropped. Increase for higher throughput with local Ollama."
380    )]
381    pub llm_max_concurrent: Option<usize>,
382
383    /// How long a network request waits for a concurrency permit
384    #[clap(
385        long = "llm-queue-timeout",
386        value_name = "SECONDS",
387        help = "How long a network-triggered LLM request waits for a free slot under --llm-max-concurrent before the protocol answers the peer with an overload error instead. Default: 120 (one backend request timeout). 0 waits forever."
388    )]
389    pub llm_queue_timeout: Option<u64>,
390
391    /// How many network requests may wait for a concurrency permit at once
392    #[clap(
393        long = "llm-max-queued",
394        value_name = "NUM",
395        help = "How many network-triggered LLM requests may wait for a free slot at once. Beyond this the protocol answers the peer with an overload error immediately rather than growing the queue. Default: 128. 0 is unbounded."
396    )]
397    pub llm_max_queued: Option<usize>,
398
399    /// Token limit per time window (optional, for cloud API usage control)
400    #[clap(
401        long = "llm-token-limit",
402        value_name = "TOKENS",
403        help = "Maximum tokens (input + output) per time window. Use for cloud API rate limiting. No limit by default (suitable for local Ollama)."
404    )]
405    pub llm_token_limit: Option<u64>,
406
407    /// Token limit time window in seconds (default: 60)
408    #[clap(
409        long = "llm-token-window",
410        value_name = "SECONDS",
411        default_value = "60",
412        help = "Time window in seconds for token limit enforcement."
413    )]
414    pub llm_token_window: u64,
415
416    /// Disable scripting (LLM will only use actions, no script generation)
417    #[clap(
418        long = "no-scripts",
419        help = "Disable script generation for tests that need action-only responses",
420        hide = true  // Hidden from help output - primarily for testing
421    )]
422    pub no_scripts: bool,
423
424    /// Load server/client configuration from a .netget file
425    #[clap(
426        long = "load",
427        value_name = "FILE",
428        help = "Load and execute server/client configurations from a .netget file"
429    )]
430    pub load_file: Option<String>,
431
432    /// Non-interactive only: exit (status 0) once this many network events have been handled
433    /// across every server and client.
434    #[clap(
435        long = "exit-after-events",
436        value_name = "N",
437        value_parser = clap::value_parser!(u64).range(1..),
438        help = "Non-interactive mode: exit with status 0 once N network events have been handled across all servers and clients (each entry in the access log counts once, whatever answered it - handler, model or operator)"
439    )]
440    pub exit_after_events: Option<u64>,
441
442    /// Non-interactive only: exit (status 0) after this many seconds.
443    #[clap(
444        long = "run-for",
445        value_name = "SECS",
446        value_parser = clap::value_parser!(u64).range(1..),
447        help = "Non-interactive mode: run for SECS seconds, then stop everything and exit with status 0"
448    )]
449    pub run_for: Option<u64>,
450
451    /// Path to mock LLM configuration file (for testing)
452    #[clap(
453        long = "mock-config-file",
454        value_name = "FILE",
455        help = "Path to JSON file containing mock LLM responses (used by tests)",
456        hide = true  // Hidden from help output - internal testing flag
457    )]
458    pub mock_config_file: Option<std::path::PathBuf>,
459
460    /// Start a simple protocol (simplified LLM interface for "dumb" models)
461    #[clap(
462        long = "simple",
463        value_name = "PROTOCOL",
464        help = "Start a simple protocol server (e.g., http). Use --simple-list to see available protocols."
465    )]
466    pub simple_protocol: Option<String>,
467
468    /// List available simple protocols
469    #[clap(
470        long = "simple-list",
471        help = "List available simple protocols that can be started with --simple"
472    )]
473    pub simple_list: bool,
474
475    /// Connect a protocol client to a remote server (non-interactive)
476    #[clap(
477        long = "client",
478        value_name = "PROTOCOL",
479        help = "Connect a client of PROTOCOL to the address given by --connect, without asking the model to do it. Any trailing text is used as the client's instruction. Use --client-list to see available protocols."
480    )]
481    pub client_protocol: Option<String>,
482
483    /// Remote address for --client
484    #[clap(
485        long = "connect",
486        value_name = "ADDRESS",
487        requires = "client_protocol",
488        help = "Remote address the --client connects to, e.g. 127.0.0.1:6379"
489    )]
490    pub client_addr: Option<String>,
491
492    /// Startup parameters for --client
493    #[clap(
494        long = "client-params",
495        value_name = "JSON",
496        requires = "client_protocol",
497        help = "JSON object of startup parameters for --client (same keys as the MCP start_client tool's startup_params)"
498    )]
499    pub client_params: Option<String>,
500
501    /// Event handlers for --client
502    #[clap(
503        long = "client-handlers",
504        value_name = "JSON",
505        requires = "client_protocol",
506        help = "JSON array of event handlers for --client (script or static handlers run in-process with NO LLM call, which is what makes a scripted client deterministic)"
507    )]
508    pub client_handlers: Option<String>,
509
510    /// List available client protocols
511    #[clap(
512        long = "client-list",
513        help = "List protocols that can be connected as clients with --client"
514    )]
515    pub client_list: bool,
516
517    /// Start a server of PROTOCOL directly, skipping the initial model call
518    #[clap(
519        long = "server",
520        value_name = "PROTOCOL",
521        conflicts_with = "client_protocol",
522        help = "Start a server of PROTOCOL directly, WITHOUT asking the model which protocol to run — the initial LLM call is skipped. Any trailing text becomes the server's per-request instruction (the general prompt used for every request). Use a base-stack name (e.g. http, dns, redis); run with --docs to see them. Combine with --port and --server-params."
523    )]
524    pub server_protocol: Option<String>,
525
526    /// Listen port for --server
527    #[clap(
528        long = "port",
529        value_name = "PORT",
530        requires = "server_protocol",
531        help = "Port the --server listens on (default is the protocol's own default)."
532    )]
533    pub server_port: Option<u16>,
534
535    /// Startup parameters for --server
536    #[clap(
537        long = "server-params",
538        value_name = "JSON",
539        requires = "server_protocol",
540        help = "JSON object of startup parameters for --server (same keys as the MCP start_server tool's startup_params, e.g. request_filter/default_response for http). Not every parameter has a dedicated flag — use this for the rest."
541    )]
542    pub server_params: Option<String>,
543
544    /// Print documentation for every protocol and exit
545    #[clap(
546        long = "docs",
547        help = "Print documentation for every compiled-in server and client protocol (base stacks, keywords, startup parameters, events, actions, examples) to stdout, then exit."
548    )]
549    pub docs: bool,
550
551    /// Run as MCP STDIO server (for Claude Desktop/Code integration)
552    #[clap(
553        long = "mcp",
554        alias = "mcp-stdio",
555        help = "Run as an MCP server over stdin/stdout for integration with MCP clients like Claude Desktop"
556    )]
557    pub mcp_stdio: bool,
558
559    /// Run as an MCP server over HTTP/SSE on the given port (for remote/web MCP clients)
560    #[clap(
561        long = "mcp-http",
562        value_name = "PORT",
563        conflicts_with = "mcp_stdio",
564        help = "Run as an MCP server over HTTP/SSE on the given port (e.g. --mcp-http 8080). Bind address comes from --listen-addr (default 127.0.0.1)"
565    )]
566    pub mcp_http: Option<u16>,
567
568    /// Bearer token every --mcp-http request must carry
569    #[clap(
570        long = "mcp-token",
571        value_name = "TOKEN",
572        help = "Require 'Authorization: Bearer <TOKEN>' on every --mcp-http request. PREFER the NETGET_MCP_TOKEN environment variable: an argument is visible to every local user in the process table. Required to bind --listen-addr to anything but a loopback address; without it the endpoint admits loopback Host/Origin only."
573    )]
574    pub mcp_token: Option<String>,
575
576    /// Prompt/command to execute (can be specified after --, or as trailing args, or via stdin)
577    #[clap(value_name = "PROMPT", num_args = 0..)]
578    pub prompt: Vec<String>,
579}
580
581/// Warn - once per process - that an API key was passed as a command-line
582/// argument.
583///
584/// A process's argv is world-readable on every platform NetGet runs on: any
585/// local user can read the key out of `ps` / `/proc/<pid>/cmdline` for as long
586/// as the process lives, and it lands in shell history and process accounting
587/// on the way in. The environment-variable path has none of those properties,
588/// so it is the documented way in; the flag keeps working for callers that
589/// already use it, but it says so.
590fn warn_api_key_on_command_line() {
591    static WARNED: std::sync::Once = std::sync::Once::new();
592    WARNED.call_once(|| {
593        const MSG: &str = "--api-key puts the key in this process's command line, where any local user can read it (ps / /proc). Prefer the NETGET_API_KEY or OPENAI_API_KEY environment variable.";
594        tracing::warn!("{}", MSG);
595        // stderr, not stdout: MCP stdio mode carries JSON-RPC on stdout.
596        eprintln!("⚠  {}", MSG);
597    });
598}
599
600/// Environment variable read when `--mcp-token` is not passed. Lives here rather than in
601/// `mcp_stdio::http_guard` because that module is compiled only with `mcp-stdio` /
602/// `mcp-http`, and `Args` is parsed at every feature set.
603pub const MCP_TOKEN_ENV: &str = "NETGET_MCP_TOKEN";
604
605/// Same warning as `warn_api_key_on_command_line`, for `--mcp-token`.
606fn warn_mcp_token_on_command_line() {
607    static WARNED: std::sync::Once = std::sync::Once::new();
608    WARNED.call_once(|| {
609        const MSG: &str = "--mcp-token puts the token in this process's command line, where any local user can read it (ps / /proc). Prefer the NETGET_MCP_TOKEN environment variable.";
610        tracing::warn!("{}", MSG);
611        eprintln!("⚠  {}", MSG);
612    });
613}
614
615impl Args {
616    /// Resolve the API key from NETGET_API_KEY / OPENAI_API_KEY, or the
617    /// `--api-key` flag.
618    ///
619    /// The flag still wins when both are set - changing that would break
620    /// callers - but using it warns, because it exposes the secret in the
621    /// process table.
622    pub fn resolve_api_key(&self) -> Option<String> {
623        if let Some(key) = &self.api_key {
624            warn_api_key_on_command_line();
625            return Some(key.clone());
626        }
627        std::env::var("NETGET_API_KEY")
628            .ok()
629            .or_else(|| std::env::var("OPENAI_API_KEY").ok())
630    }
631
632    /// Resolve the MCP HTTP bearer token from `--mcp-token` or `NETGET_MCP_TOKEN`.
633    ///
634    /// Same shape as `resolve_api_key`: the flag wins and warns, the variable is
635    /// the documented way in. An empty value counts as unset.
636    pub fn resolve_mcp_token(&self) -> Option<String> {
637        if let Some(token) = &self.mcp_token {
638            warn_mcp_token_on_command_line();
639            return Some(token.clone()).filter(|t| !t.is_empty());
640        }
641        std::env::var(MCP_TOKEN_ENV).ok().filter(|t| !t.is_empty())
642    }
643
644    /// Get the effective log level from --log-level flag
645    pub fn effective_log_level(&self) -> Level {
646        match self.log_level.to_lowercase().as_str() {
647            "off" | "none" => Level::ERROR, // We'll filter this out separately
648            "error" => Level::ERROR,
649            "warn" | "warning" => Level::WARN,
650            "info" => Level::INFO,
651            "debug" => Level::DEBUG,
652            "trace" => Level::TRACE,
653            _ => Level::ERROR,
654        }
655    }
656
657    /// Check if logging should be disabled entirely
658    pub fn logging_disabled(&self) -> bool {
659        self.log_level == "off" || self.log_level == "none"
660    }
661
662    /// Determine if we should run in interactive mode
663    pub fn is_interactive(&self) -> bool {
664        // Non-interactive if we have a prompt from args
665        if !self.prompt.is_empty() {
666            return false;
667        }
668
669        // Non-interactive if stdin is not a terminal (piped input)
670        if !io::stdin().is_terminal() {
671            return false;
672        }
673
674        // Non-interactive if stdout is not a terminal (piped output)
675        // This ensures we don't try to show TUI when output is redirected
676        if !io::stdout().is_terminal() {
677            return false;
678        }
679
680        // Otherwise, run in interactive mode
681        true
682    }
683
684    /// Get the prompt to execute, from various sources
685    /// Returns None if the input should be treated as actions JSON instead
686    pub fn get_prompt(&self) -> Result<Option<String>> {
687        // First priority: --load flag (will be handled separately)
688        if self.load_file.is_some() {
689            return Ok(None);
690        }
691
692        // Second priority: trailing arguments after command
693        if !self.prompt.is_empty() {
694            let joined = self.prompt.join(" ");
695            // Check if it's actions JSON instead of a prompt
696            if crate::utils::save_load::is_actions_json(&joined) {
697                // This will be handled by get_actions_json() instead
698                return Ok(None);
699            }
700            return Ok(Some(joined));
701        }
702
703        // Third priority: stdin if not a terminal (piped/redirected input)
704        let piped = piped_stdin();
705        if !piped.is_empty() {
706            // Check if it's actions JSON instead of a prompt
707            if crate::utils::save_load::is_actions_json(piped) {
708                // This will be handled by get_actions_json() instead
709                return Ok(None);
710            }
711            return Ok(Some(piped.to_string()));
712        }
713
714        // No prompt available
715        Ok(None)
716    }
717
718    /// Get actions JSON to execute, from various sources
719    /// Returns None if the input is a regular prompt or no input
720    pub fn get_actions_json(&self) -> Result<Option<Vec<serde_json::Value>>> {
721        use crate::utils::save_load;
722
723        // First priority: --load flag
724        if let Some(ref filename) = self.load_file {
725            // This will fail if file doesn't exist, which is appropriate
726            return Ok(Some(load_actions_file(filename)?));
727        }
728
729        // Second priority: trailing arguments after command
730        if !self.prompt.is_empty() {
731            let joined = self.prompt.join(" ");
732            if save_load::is_actions_json(&joined) {
733                // Parse {"actions": [...]} format and extract the array
734                let parsed: serde_json::Value = serde_json::from_str(&joined)?;
735                let actions = parsed["actions"]
736                    .as_array()
737                    .ok_or_else(|| anyhow::anyhow!("Invalid actions format"))?
738                    .clone();
739                return Ok(Some(actions));
740            }
741        }
742
743        // Third priority: stdin, but ONLY when no trailing prompt was given.
744        //
745        // When trailing args are present the prompt/actions come from there, and
746        // stdin is not a source — it may instead be a live pipe feeding a stdio
747        // server (`prog | netget "be a filter"`). Consulting `piped_stdin()`
748        // there would block forever on that never-EOF pipe before any server
749        // starts. With no trailing args stdin IS the source, so
750        // `echo '{"actions":...}' | netget` and `cat prompt.txt | netget` still
751        // read it (those pipes reach EOF).
752        if self.prompt.is_empty() {
753            let piped = piped_stdin();
754            if !piped.is_empty() && save_load::is_actions_json(piped) {
755                // Parse {"actions": [...]} format and extract the array
756                let parsed: serde_json::Value = serde_json::from_str(piped)?;
757                let actions = parsed["actions"]
758                    .as_array()
759                    .ok_or_else(|| anyhow::anyhow!("Invalid actions format"))?
760                    .clone();
761                return Ok(Some(actions));
762            }
763        }
764
765        // No actions JSON available
766        Ok(None)
767    }
768
769    /// Check if the environment is suitable for the requested mode
770    pub fn validate_mode(&self) -> Result<()> {
771        if self.is_interactive() {
772            // Interactive mode requires a terminal
773            if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
774                anyhow::bail!(
775                    "Cannot start in interactive mode without a terminal.\n\
776                     Please provide a prompt via arguments, stdin, or use --non-interactive."
777                );
778            }
779        } else {
780            // Non-interactive mode requires a prompt
781            if self.get_prompt()?.is_none() {
782                anyhow::bail!(
783                    "Non-interactive mode requires a prompt.\n\
784                     Provide a prompt via arguments, stdin, or use interactive mode."
785                );
786            }
787        }
788        Ok(())
789    }
790
791    /// Parse the scripting environment argument into a ScriptingMode
792    pub fn parse_scripting_mode(&self) -> Result<Option<crate::state::app_state::ScriptingMode>> {
793        // --no-scripts flag takes precedence
794        if self.no_scripts {
795            return Ok(Some(crate::state::app_state::ScriptingMode::Off));
796        }
797
798        match &self.scripting_env {
799            None => Ok(None),
800            Some(env) => {
801                let mode = match env.to_lowercase().as_str() {
802                    "on" | "auto" => crate::state::app_state::ScriptingMode::On,
803                    "off" | "llm" => crate::state::app_state::ScriptingMode::Off,
804                    "python" | "py" => crate::state::app_state::ScriptingMode::Python,
805                    "javascript" | "js" | "node" => {
806                        crate::state::app_state::ScriptingMode::JavaScript
807                    }
808                    "go" | "golang" => crate::state::app_state::ScriptingMode::Go,
809                    "perl" => crate::state::app_state::ScriptingMode::Perl,
810                    _ => {
811                        anyhow::bail!(
812                            "Invalid scripting environment: '{}'\n\
813                             Valid options: on (auto), off (llm), python (py), javascript (js, node), go (golang), perl",
814                            env
815                        );
816                    }
817                };
818                Ok(Some(mode))
819            }
820        }
821    }
822
823    /// Parse the event handler mode argument into an EventHandlerMode
824    pub fn parse_event_handler_mode(
825        &self,
826    ) -> Result<Option<crate::state::app_state::EventHandlerMode>> {
827        match &self.event_handler_mode {
828            None => Ok(None),
829            Some(mode) => {
830                let parsed_mode = match mode.to_lowercase().as_str() {
831                    "any" => crate::state::app_state::EventHandlerMode::Any,
832                    "script" => crate::state::app_state::EventHandlerMode::Script,
833                    "static" => crate::state::app_state::EventHandlerMode::Static,
834                    "llm" => crate::state::app_state::EventHandlerMode::Llm,
835                    _ => {
836                        anyhow::bail!(
837                            "Invalid event handler mode: '{}'\n\
838                             Valid options: any, script, static, llm",
839                            mode
840                        );
841                    }
842                };
843                Ok(Some(parsed_mode))
844            }
845        }
846    }
847
848    /// Instruction for a `--client`: the trailing text if any was given,
849    /// otherwise the same default the MCP `start_client` tool uses.
850    pub fn client_instruction(&self, protocol: &str, remote_addr: &str) -> String {
851        let trailing = self.prompt.join(" ").trim().to_string();
852        if trailing.is_empty() {
853            format!(
854                "You are a {} client connected to {}. Handle responses appropriately.",
855                protocol, remote_addr
856            )
857        } else {
858            trailing
859        }
860    }
861
862    /// The per-request instruction for `--server`: the trailing prompt, or a
863    /// minimal default naming the protocol when none was given.
864    pub fn server_instruction(&self, protocol: &str) -> String {
865        let trailing = self.prompt.join(" ").trim().to_string();
866        if trailing.is_empty() {
867            format!(
868                "You are a {} server. Respond to each request appropriately.",
869                protocol
870            )
871        } else {
872            trailing
873        }
874    }
875
876    /// Parse `--server-params` into a JSON object.
877    pub fn parse_server_params(&self) -> Result<Option<serde_json::Value>> {
878        match &self.server_params {
879            None => Ok(None),
880            Some(raw) => {
881                let value: serde_json::Value = serde_json::from_str(raw)
882                    .map_err(|e| anyhow::anyhow!("--server-params is not valid JSON: {}", e))?;
883                if !value.is_object() {
884                    anyhow::bail!(
885                        "--server-params must be a JSON object, e.g. '{{\"request_filter\": [...]}}'"
886                    );
887                }
888                Ok(Some(value))
889            }
890        }
891    }
892
893    /// Parse `--client-params` into a JSON object
894    pub fn parse_client_params(&self) -> Result<Option<serde_json::Value>> {
895        match &self.client_params {
896            None => Ok(None),
897            Some(raw) => {
898                let value: serde_json::Value = serde_json::from_str(raw)
899                    .map_err(|e| anyhow::anyhow!("--client-params is not valid JSON: {}", e))?;
900                if !value.is_object() {
901                    anyhow::bail!("--client-params must be a JSON object, e.g. '{{\"db\": 0}}'");
902                }
903                Ok(Some(value))
904            }
905        }
906    }
907
908    /// Parse `--client-handlers` into a list of event handler definitions
909    pub fn parse_client_handlers(&self) -> Result<Option<Vec<serde_json::Value>>> {
910        match &self.client_handlers {
911            None => Ok(None),
912            Some(raw) => {
913                let value: serde_json::Value = serde_json::from_str(raw)
914                    .map_err(|e| anyhow::anyhow!("--client-handlers is not valid JSON: {}", e))?;
915                match value {
916                    serde_json::Value::Array(handlers) => Ok(Some(handlers)),
917                    _ => anyhow::bail!(
918                        "--client-handlers must be a JSON array of handler objects, e.g. '[{{\"event\": \"redis_response_received\", \"handler_type\": \"static\", \"response\": []}}]'"
919                    ),
920                }
921            }
922        }
923    }
924
925    /// Parse `--min-stability` into a [`DevelopmentState`].
926    ///
927    /// Returns `Ok(None)` when the flag is absent (no gate — current behaviour).
928    /// An unrecognised value is a hard error naming the allowed set, mirroring
929    /// `parse_scripting_mode`/`parse_event_handler_mode`.
930    pub fn parse_min_stability(
931        &self,
932    ) -> Result<Option<crate::protocol::metadata::DevelopmentState>> {
933        match &self.min_stability {
934            None => Ok(None),
935            Some(raw) => match crate::protocol::metadata::DevelopmentState::parse_ci(raw) {
936                Some(state) => Ok(Some(state)),
937                None => anyhow::bail!(
938                    "Invalid --min-stability value: '{}'\n\
939                     Valid options (case-insensitive): incomplete, experimental, beta, stable",
940                    raw
941                ),
942            },
943        }
944    }
945
946    /// Build a RateLimiterConfig from CLI arguments
947    pub fn build_rate_limiter_config(&self) -> crate::llm::RateLimiterConfig {
948        crate::llm::RateLimiterConfig {
949            max_concurrent: self.llm_max_concurrent.unwrap_or(1),
950            token_limit: self.llm_token_limit,
951            token_window_secs: self.llm_token_window,
952            queue_timeout_secs: self
953                .llm_queue_timeout
954                .unwrap_or(crate::llm::DEFAULT_QUEUE_TIMEOUT_SECS),
955            max_queued: self
956                .llm_max_queued
957                .unwrap_or(crate::llm::DEFAULT_MAX_QUEUED),
958        }
959    }
960}
961
962/// A temperature is a finite, non-negative number. Rejected here rather than sent, because
963/// backends disagree on what they do with a negative or NaN value and none of them says so.
964fn parse_temperature(value: &str) -> Result<f32, String> {
965    let t: f32 = value
966        .parse()
967        .map_err(|_| format!("'{}' is not a number", value))?;
968    if !t.is_finite() || t < 0.0 {
969        return Err(format!(
970            "temperature must be a finite number >= 0, got {}",
971            value
972        ));
973    }
974    Ok(t)
975}