1use std::path::PathBuf;
8
9#[derive(Debug, Clone)]
11pub struct CliConfig {
12 pub base_url: String,
13 pub default_model: String,
14 pub data_dir: Option<PathBuf>,
15 pub integrity_key_path: Option<PathBuf>,
16 pub require_integrity_key: bool,
17 pub ephemeral: bool,
18}
19
20impl Default for CliConfig {
21 fn default() -> Self {
22 Self {
23 base_url: "http://127.0.0.1:11434".to_string(),
24 default_model: "glm-5.2:cloud".to_string(),
25 data_dir: None,
26 integrity_key_path: None,
27 require_integrity_key: false,
28 ephemeral: false,
29 }
30 }
31}
32
33#[derive(Debug, Clone)]
36pub struct CliError {
37 pub message: String,
38 pub exit_code: i32,
39}
40
41impl CliError {
42 pub fn new(message: impl Into<String>) -> Self {
43 Self {
44 message: message.into(),
45 exit_code: 2,
46 }
47 }
48}
49
50impl std::fmt::Display for CliError {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 write!(f, "{}", self.message)
53 }
54}
55
56impl std::error::Error for CliError {}
57
58#[derive(Debug, Clone)]
59pub struct ProxyConfig {
60 pub socket: PathBuf,
61 pub timeout_ms: u64,
62}
63pub fn parse_proxy_args(args: &[String]) -> Result<ProxyConfig, CliError> {
64 let mut socket = std::env::var_os("XDG_RUNTIME_DIR")
65 .map(PathBuf::from)
66 .unwrap_or_else(|| PathBuf::from("/tmp"))
67 .join("agent-graph/mcp.sock");
68 let mut timeout_ms = 2000;
69 let mut i = 0;
70 while i < args.len() {
71 match args[i].as_str() {
72 "--help" => {
73 return Err(CliError {
74 message: "agent-graph-mcp [--socket PATH] [--connect-timeout-ms N]".into(),
75 exit_code: 0,
76 })
77 }
78 "--version" => {
79 return Err(CliError {
80 message: env!("CARGO_PKG_VERSION").into(),
81 exit_code: 0,
82 })
83 }
84 "--socket" => {
85 i += 1;
86 socket = PathBuf::from(
87 args.get(i)
88 .ok_or_else(|| CliError::new("--socket requires a value"))?,
89 );
90 }
91 "--connect-timeout-ms" => {
92 i += 1;
93 timeout_ms = args
94 .get(i)
95 .ok_or_else(|| CliError::new("--connect-timeout-ms requires a value"))?
96 .parse()
97 .map_err(|_| CliError::new("invalid timeout"))?;
98 }
99 "--data-dir" | "--integrity-key" | "--base-url" | "--model" => {
100 return Err(CliError::new("LEGACY_DIRECT_DURABLE_UNSUPPORTED"))
101 }
102 other => return Err(CliError::new(format!("unknown argument: '{other}'"))),
103 }
104 i += 1;
105 }
106 Ok(ProxyConfig { socket, timeout_ms })
107}
108
109pub fn parse_args(args: &[String]) -> Result<CliConfig, CliError> {
119 let mut config = CliConfig::default();
120 let mut iter = args.iter().peekable();
121
122 while let Some(arg) = iter.next() {
123 match arg.as_str() {
124 "--base-url" => {
125 let value = iter
126 .next()
127 .ok_or_else(|| CliError::new("--base-url requires a value"))?;
128 validate_url(value)?;
129 config.base_url = value.clone();
130 }
131 "--model" => {
132 let value = iter
133 .next()
134 .ok_or_else(|| CliError::new("--model requires a value"))?;
135 if value.is_empty() {
136 return Err(CliError::new("--model value must not be empty"));
137 }
138 config.default_model = value.clone();
139 }
140 "--data-dir" => {
141 let value = iter
142 .next()
143 .ok_or_else(|| CliError::new("--data-dir requires a value"))?;
144 if value.is_empty() {
145 return Err(CliError::new("--data-dir value must not be empty"));
146 }
147 config.data_dir = Some(PathBuf::from(value));
148 }
149 "--integrity-key" => {
150 let value = iter
151 .next()
152 .ok_or_else(|| CliError::new("--integrity-key requires a value"))?;
153 if value.is_empty() {
154 return Err(CliError::new("--integrity-key value must not be empty"));
155 }
156 config.integrity_key_path = Some(PathBuf::from(value));
157 }
158 "--require-integrity-key" => {
159 config.require_integrity_key = true;
160 }
161 "--ephemeral" => {
162 config.ephemeral = true;
163 }
164 "--help" => {
165 eprintln!("agent-graph-mcp [OPTIONS]");
166 eprintln!();
167 eprintln!("Options:");
168 eprintln!(
169 " --base-url <url> Provider URL (default: http://127.0.0.1:11434)"
170 );
171 eprintln!(" --model <name> Default model for LLM nodes (default: glm-5.2:cloud)");
172 eprintln!(" --data-dir <path> Persistent storage directory");
173 eprintln!(" --integrity-key <path> Integrity key file for durable mode");
174 eprintln!(" --require-integrity-key Fail startup if integrity key is missing/unreadable");
175 eprintln!(" --ephemeral Explicit in-memory mode (no persistence)");
176 eprintln!(" --help Show this help message");
177 return Err(CliError {
178 message: String::new(),
179 exit_code: 0,
180 });
181 }
182 _ => {
183 return Err(CliError::new(format!(
184 "unknown argument: '{arg}' — use --help for usage"
185 )));
186 }
187 }
188 }
189
190 if config.ephemeral && config.data_dir.is_some() {
192 return Err(CliError::new(
193 "--ephemeral and --data-dir are mutually exclusive",
194 ));
195 }
196
197 if config.require_integrity_key && config.data_dir.is_none() {
198 return Err(CliError::new(
199 "--require-integrity-key requires --data-dir (durable mode)",
200 ));
201 }
202
203 Ok(config)
204}
205
206fn validate_url(url: &str) -> Result<(), CliError> {
208 let (scheme, rest) = url
209 .split_once("://")
210 .ok_or_else(|| CliError::new(format!("invalid URL (no scheme): {url}")))?;
211
212 if scheme != "http" && scheme != "https" {
213 return Err(CliError::new(format!(
214 "unsupported URL scheme '{scheme}': only http and https are allowed"
215 )));
216 }
217
218 let authority = rest.split_once('/').map(|(auth, _)| auth).unwrap_or(rest);
219 let host = authority
220 .rsplit_once('@')
221 .map(|(_, host)| host)
222 .unwrap_or(authority);
223
224 if host.is_empty() {
225 return Err(CliError::new(format!("invalid URL (empty host): {url}")));
226 }
227
228 Ok(())
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 fn args(flags: &[&str]) -> Vec<String> {
236 flags.iter().map(|s| s.to_string()).collect()
237 }
238
239 #[test]
240 fn test_unknown_flag_rejected() {
241 let result = parse_args(&args(&["--dat-dir", "/secure/path"]));
242 assert!(result.is_err());
243 assert!(result.unwrap_err().message.contains("unknown argument"));
244 }
245
246 #[test]
247 fn test_missing_value_for_base_url() {
248 let result = parse_args(&args(&["--base-url"]));
249 assert!(result.is_err());
250 assert!(result.unwrap_err().message.contains("requires a value"));
251 }
252
253 #[test]
254 fn test_missing_value_for_model() {
255 let result = parse_args(&args(&["--model"]));
256 assert!(result.is_err());
257 assert!(result.unwrap_err().message.contains("requires a value"));
258 }
259
260 #[test]
261 fn test_missing_value_for_data_dir() {
262 let result = parse_args(&args(&["--data-dir"]));
263 assert!(result.is_err());
264 assert!(result.unwrap_err().message.contains("requires a value"));
265 }
266
267 #[test]
268 fn test_empty_model_value_rejected() {
269 let result = parse_args(&args(&["--model", ""]));
270 assert!(result.is_err());
271 assert!(result.unwrap_err().message.contains("must not be empty"));
272 }
273
274 #[test]
275 fn test_empty_data_dir_value_rejected() {
276 let result = parse_args(&args(&["--data-dir", ""]));
277 assert!(result.is_err());
278 assert!(result.unwrap_err().message.contains("must not be empty"));
279 }
280
281 #[test]
282 fn test_non_http_url_rejected() {
283 let result = parse_args(&args(&["--base-url", "ftp://example.com"]));
284 assert!(result.is_err());
285 assert!(result
286 .unwrap_err()
287 .message
288 .contains("unsupported URL scheme"));
289 }
290
291 #[test]
292 fn test_no_scheme_url_rejected() {
293 let result = parse_args(&args(&["--base-url", "example.com"]));
294 assert!(result.is_err());
295 assert!(result.unwrap_err().message.contains("no scheme"));
296 }
297
298 #[test]
299 fn test_empty_host_url_rejected() {
300 let result = parse_args(&args(&["--base-url", "http://"]));
301 assert!(result.is_err());
302 assert!(result.unwrap_err().message.contains("empty host"));
303 }
304
305 #[test]
306 fn test_valid_http_url_accepted() {
307 let result = parse_args(&args(&["--base-url", "http://127.0.0.1:11434"]));
308 assert!(result.is_ok());
309 assert_eq!(result.unwrap().base_url, "http://127.0.0.1:11434");
310 }
311
312 #[test]
313 fn test_valid_https_url_accepted() {
314 let result = parse_args(&args(&["--base-url", "https://api.openai.com"]));
315 assert!(result.is_ok());
316 assert_eq!(result.unwrap().base_url, "https://api.openai.com");
317 }
318
319 #[test]
320 fn test_url_with_credentials_stripped_in_validation() {
321 let result = parse_args(&args(&["--base-url", "https://user:pass@host.com"]));
322 assert!(result.is_ok()); }
324
325 #[test]
326 fn test_ephemeral_and_data_dir_mutually_exclusive() {
327 let result = parse_args(&args(&["--ephemeral", "--data-dir", "/tmp/test"]));
328 assert!(result.is_err());
329 assert!(result.unwrap_err().message.contains("mutually exclusive"));
330 }
331
332 #[test]
333 fn test_require_integrity_key_without_data_dir_rejected() {
334 let result = parse_args(&args(&["--require-integrity-key"]));
335 assert!(result.is_err());
336 assert!(result.unwrap_err().message.contains("requires --data-dir"));
337 }
338
339 #[test]
340 fn test_require_integrity_key_with_data_dir_accepted() {
341 let result = parse_args(&args(&[
342 "--require-integrity-key",
343 "--data-dir",
344 "/tmp/test",
345 "--integrity-key",
346 "/tmp/key",
347 ]));
348 assert!(result.is_ok());
349 assert!(result.unwrap().require_integrity_key);
350 }
351
352 #[test]
353 fn test_help_returns_exit_zero() {
354 let result = parse_args(&args(&["--help"]));
355 assert!(result.is_err());
356 assert_eq!(result.unwrap_err().exit_code, 0);
357 }
358
359 #[test]
360 fn test_default_config_when_no_args() {
361 let result = parse_args(&[]);
362 assert!(result.is_ok());
363 let config = result.unwrap();
364 assert_eq!(config.base_url, "http://127.0.0.1:11434");
365 assert_eq!(config.default_model, "glm-5.2:cloud");
366 assert!(config.data_dir.is_none());
367 assert!(!config.ephemeral);
368 }
369
370 #[test]
371 fn test_ephemeral_alone_accepted() {
372 let result = parse_args(&args(&["--ephemeral"]));
373 assert!(result.is_ok());
374 assert!(result.unwrap().ephemeral);
375 }
376
377 #[test]
378 fn test_integrity_key_path_recorded() {
379 let result = parse_args(&args(&[
380 "--data-dir",
381 "/tmp/test",
382 "--integrity-key",
383 "/tmp/my-key",
384 ]));
385 assert!(result.is_ok());
386 let config = result.unwrap();
387 assert_eq!(
388 config.integrity_key_path,
389 Some(PathBuf::from("/tmp/my-key"))
390 );
391 }
392}