1use anyhow::Result;
4use clap::Parser;
5use std::io::{self, IsTerminal, Read};
6use tracing::Level;
7
8fn default_log_level() -> String {
20 if cfg!(debug_assertions) {
21 "debug".to_string()
22 } else {
23 "info".to_string()
24 }
25}
26
27fn 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
68fn 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() }
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#[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 #[clap(short = 'm', long = "model", value_name = "MODEL")]
164 pub model: Option<String>,
165
166 #[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 #[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 #[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 #[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 #[clap(
207 long = "include-disabled-protocols",
208 help = "Includes experimental protocols for testing purposes"
209 )]
210 pub include_disabled_protocols: bool,
211
212 #[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 #[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 #[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 )]
257 pub ollama_url: Option<String>,
258
259 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[clap(
418 long = "no-scripts",
419 help = "Disable script generation for tests that need action-only responses",
420 hide = true )]
422 pub no_scripts: bool,
423
424 #[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 #[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 #[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 #[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 )]
458 pub mock_config_file: Option<std::path::PathBuf>,
459
460 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[clap(value_name = "PROMPT", num_args = 0..)]
578 pub prompt: Vec<String>,
579}
580
581fn 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 eprintln!("⚠ {}", MSG);
597 });
598}
599
600pub const MCP_TOKEN_ENV: &str = "NETGET_MCP_TOKEN";
604
605fn 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 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 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 pub fn effective_log_level(&self) -> Level {
646 match self.log_level.to_lowercase().as_str() {
647 "off" | "none" => Level::ERROR, "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 pub fn logging_disabled(&self) -> bool {
659 self.log_level == "off" || self.log_level == "none"
660 }
661
662 pub fn is_interactive(&self) -> bool {
664 if !self.prompt.is_empty() {
666 return false;
667 }
668
669 if !io::stdin().is_terminal() {
671 return false;
672 }
673
674 if !io::stdout().is_terminal() {
677 return false;
678 }
679
680 true
682 }
683
684 pub fn get_prompt(&self) -> Result<Option<String>> {
687 if self.load_file.is_some() {
689 return Ok(None);
690 }
691
692 if !self.prompt.is_empty() {
694 let joined = self.prompt.join(" ");
695 if crate::utils::save_load::is_actions_json(&joined) {
697 return Ok(None);
699 }
700 return Ok(Some(joined));
701 }
702
703 let piped = piped_stdin();
705 if !piped.is_empty() {
706 if crate::utils::save_load::is_actions_json(piped) {
708 return Ok(None);
710 }
711 return Ok(Some(piped.to_string()));
712 }
713
714 Ok(None)
716 }
717
718 pub fn get_actions_json(&self) -> Result<Option<Vec<serde_json::Value>>> {
721 use crate::utils::save_load;
722
723 if let Some(ref filename) = self.load_file {
725 return Ok(Some(load_actions_file(filename)?));
727 }
728
729 if !self.prompt.is_empty() {
731 let joined = self.prompt.join(" ");
732 if save_load::is_actions_json(&joined) {
733 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 if self.prompt.is_empty() {
753 let piped = piped_stdin();
754 if !piped.is_empty() && save_load::is_actions_json(piped) {
755 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 Ok(None)
767 }
768
769 pub fn validate_mode(&self) -> Result<()> {
771 if self.is_interactive() {
772 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 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 pub fn parse_scripting_mode(&self) -> Result<Option<crate::state::app_state::ScriptingMode>> {
793 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 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 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 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 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 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 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 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 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
962fn 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}