1use std::env;
2use std::path::PathBuf;
3
4use crate::model_settings::ModelSettings;
5
6#[derive(Debug, Clone, PartialEq)]
7pub struct CliArgs {
8 pub prompt: String,
9 pub backend: String,
10 pub model: String,
11 pub settings_file: PathBuf,
12 pub workspace: PathBuf,
13 pub max_cycles: u32,
14 pub language: String,
15 pub agent_type: Option<String>,
16 pub model_settings: Option<ModelSettings>,
17 pub verbose: bool,
18}
19
20#[derive(Debug, Clone, PartialEq)]
21#[allow(clippy::large_enum_variant)] pub enum CliCommand {
23 Run(CliArgs),
24 AppServer(AppServerCliCommand),
25 Debug(DebugCliCommand),
26}
27
28#[derive(Debug, Clone, PartialEq)]
29pub enum AppServerCliCommand {
30 ListenStdio {
31 settings_file: PathBuf,
32 backend: String,
33 model: String,
34 timeout_seconds: f64,
35 },
36 GenerateTs {
37 out: PathBuf,
38 },
39 GenerateJsonSchema {
40 out: PathBuf,
41 },
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum DebugCliCommand {
46 AppServerSendMessage { message: String },
47}
48
49pub fn parse_cli_args_from<I, S>(args: I) -> Result<CliArgs, String>
50where
51 I: IntoIterator<Item = S>,
52 S: Into<String>,
53{
54 let default_settings = default_settings_file_from_environment();
55 parse_cli_args_from_with_default_settings(args, default_settings)
56}
57
58pub fn parse_cli_command_from<I, S>(args: I) -> Result<CliCommand, String>
59where
60 I: IntoIterator<Item = S>,
61 S: Into<String>,
62{
63 let default_settings = default_settings_file_from_environment();
64 parse_cli_command_from_with_default_settings(args, default_settings)
65}
66
67fn default_settings_file_from_environment() -> String {
68 non_blank_environment_value("VV_AGENT_LOCAL_SETTINGS")
69 .unwrap_or_else(|| "local_settings.json".to_string())
70}
71
72fn non_blank_environment_value(name: &str) -> Option<String> {
73 env::var(name).ok().filter(|value| !value.trim().is_empty())
74}
75
76pub fn parse_cli_command_from_with_default_settings<I, S>(
77 args: I,
78 default_settings: impl Into<String>,
79) -> Result<CliCommand, String>
80where
81 I: IntoIterator<Item = S>,
82 S: Into<String>,
83{
84 let mut values = args.into_iter().map(Into::into).collect::<Vec<_>>();
85 if !values.is_empty() {
86 values.remove(0);
87 }
88 match values.first().map(String::as_str) {
89 Some("app-server") => parse_app_server_command(&values[1..]).map(CliCommand::AppServer),
90 Some("debug") => parse_debug_command(&values[1..]).map(CliCommand::Debug),
91 _ => parse_cli_args_from_with_default_settings(
92 std::iter::once("vv-agent".to_string()).chain(values),
93 default_settings,
94 )
95 .map(CliCommand::Run),
96 }
97}
98
99pub fn parse_cli_args_from_with_default_settings<I, S>(
100 args: I,
101 default_settings: impl Into<String>,
102) -> Result<CliArgs, String>
103where
104 I: IntoIterator<Item = S>,
105 S: Into<String>,
106{
107 let mut values = args.into_iter().map(Into::into).collect::<Vec<_>>();
108 if !values.is_empty() {
109 values.remove(0);
110 }
111
112 let mut parsed = ParsedCliArgs::with_default_settings(default_settings.into());
113 parsed.consume(values)?;
114 parsed.finish()
115}
116
117fn parse_app_server_command(values: &[String]) -> Result<AppServerCliCommand, String> {
118 match values.first().map(String::as_str) {
119 Some("generate-ts") => Ok(AppServerCliCommand::GenerateTs {
120 out: parse_out_dir(&values[1..], "app-server generate-ts")?,
121 }),
122 Some("schema") => Ok(AppServerCliCommand::GenerateJsonSchema {
123 out: parse_out_dir(&values[1..], "app-server schema")?,
124 }),
125 _ => parse_app_server_listener(values),
126 }
127}
128
129fn parse_app_server_listener(values: &[String]) -> Result<AppServerCliCommand, String> {
130 let values = normalize_equals_arguments(values.to_vec());
131 let mut parsed = ParsedAppServerListener::default();
132 parsed.consume(&values)?;
133 parsed.finish()
134}
135
136#[derive(Default)]
137struct ParsedAppServerListener {
138 listen: Option<String>,
139 settings_file: Option<PathBuf>,
140 backend: Option<String>,
141 model: Option<String>,
142 timeout_seconds: Option<f64>,
143}
144
145impl ParsedAppServerListener {
146 fn consume(&mut self, values: &[String]) -> Result<(), String> {
147 let mut index = 0;
148 while index < values.len() {
149 let flag = &values[index];
150 index += 1;
151 match flag.as_str() {
152 "--listen" => {
153 reject_duplicate(self.listen.is_some(), flag)?;
154 let listen = next_non_blank_value(values, &mut index, flag)?;
155 if listen != "stdio" {
156 return Err("only app-server --listen stdio is supported".to_string());
157 }
158 self.listen = Some(listen);
159 }
160 "--settings" => {
161 reject_duplicate(self.settings_file.is_some(), flag)?;
162 self.settings_file = Some(PathBuf::from(next_non_blank_value(
163 values, &mut index, flag,
164 )?));
165 }
166 "--backend" => {
167 reject_duplicate(self.backend.is_some(), flag)?;
168 self.backend = Some(next_non_blank_value(values, &mut index, flag)?);
169 }
170 "--model" => {
171 reject_duplicate(self.model.is_some(), flag)?;
172 self.model = Some(next_non_blank_value(values, &mut index, flag)?);
173 }
174 "--timeout-seconds" => {
175 reject_duplicate(self.timeout_seconds.is_some(), flag)?;
176 let raw = next_non_blank_value(values, &mut index, flag)?;
177 self.timeout_seconds = Some(parse_positive_f64(&raw, flag)?);
178 }
179 other => return Err(format!("unknown app-server argument: {other}")),
180 }
181 }
182 Ok(())
183 }
184
185 fn finish(self) -> Result<AppServerCliCommand, String> {
186 let mut missing = Vec::new();
187 if self.listen.is_none() {
188 missing.push("--listen");
189 }
190 if self.settings_file.is_none() {
191 missing.push("--settings");
192 }
193 if self.backend.is_none() {
194 missing.push("--backend");
195 }
196 if self.model.is_none() {
197 missing.push("--model");
198 }
199 if !missing.is_empty() {
200 return Err(format!("app-server requires {}", missing.join(", ")));
201 }
202
203 Ok(AppServerCliCommand::ListenStdio {
204 settings_file: self.settings_file.expect("checked above"),
205 backend: self.backend.expect("checked above"),
206 model: self.model.expect("checked above"),
207 timeout_seconds: self.timeout_seconds.unwrap_or(90.0),
208 })
209 }
210}
211
212fn reject_duplicate(seen: bool, flag: &str) -> Result<(), String> {
213 if seen {
214 return Err(format!("duplicate app-server argument: {flag}"));
215 }
216 Ok(())
217}
218
219fn next_non_blank_value(
220 values: &[String],
221 index: &mut usize,
222 flag: &str,
223) -> Result<String, String> {
224 let value = next_value(values, index, flag)?;
225 if value.trim().is_empty() {
226 return Err(format!("{flag} requires a value"));
227 }
228 Ok(value)
229}
230
231fn parse_debug_command(values: &[String]) -> Result<DebugCliCommand, String> {
232 if values.first().map(String::as_str) == Some("app-server")
233 && values.get(1).map(String::as_str) == Some("send-message")
234 && values.len() >= 3
235 {
236 return Ok(DebugCliCommand::AppServerSendMessage {
237 message: values[2..].join(" "),
238 });
239 }
240 Err(format!("unknown debug command\n\n{}", help_text()))
241}
242
243fn parse_out_dir(values: &[String], command: &str) -> Result<PathBuf, String> {
244 let values = normalize_equals_arguments(values.to_vec());
245 if values.first().map(String::as_str) != Some("--out") || values.len() != 2 {
246 return Err(format!("{command} requires --out <dir>"));
247 }
248 let out = values.get(1).expect("length checked above");
249 if out.trim().is_empty() || cli_flag(out) {
250 return Err(format!("{command} requires --out <dir>"));
251 }
252 Ok(PathBuf::from(out))
253}
254
255struct ParsedCliArgs {
256 prompt: Option<String>,
257 backend: String,
258 model: String,
259 settings_file: PathBuf,
260 workspace: PathBuf,
261 max_cycles: u32,
262 language: String,
263 agent_type: Option<String>,
264 temperature: Option<f64>,
265 top_p: Option<f64>,
266 max_tokens: Option<u32>,
267 verbose: bool,
268}
269
270impl ParsedCliArgs {
271 fn with_default_settings(default_settings: String) -> Self {
272 Self {
273 prompt: None,
274 backend: "moonshot".to_string(),
275 model: "kimi-k3".to_string(),
276 settings_file: PathBuf::from(default_settings),
277 workspace: PathBuf::from("./workspace"),
278 max_cycles: 80,
279 language: "zh-CN".to_string(),
280 agent_type: None,
281 temperature: None,
282 top_p: None,
283 max_tokens: None,
284 verbose: false,
285 }
286 }
287
288 fn consume(&mut self, values: Vec<String>) -> Result<(), String> {
289 let values = normalize_equals_arguments(values);
290 let mut index = 0;
291 while index < values.len() {
292 let flag = &values[index];
293 index += 1;
294 match flag.as_str() {
295 "--prompt" => self.prompt = Some(next_prompt(&values, &mut index)?),
296 "--backend" => self.backend = next_value(&values, &mut index, "--backend")?,
297 "--model" => self.model = next_value(&values, &mut index, "--model")?,
298 "--settings-file" => {
299 self.settings_file =
300 PathBuf::from(next_value(&values, &mut index, "--settings-file")?)
301 }
302 "--workspace" => {
303 self.workspace = PathBuf::from(next_value(&values, &mut index, "--workspace")?)
304 }
305 "--max-cycles" => {
306 let raw = next_value(&values, &mut index, "--max-cycles")?;
307 self.max_cycles = raw
308 .parse::<u32>()
309 .map_err(|_| "--max-cycles must be an integer".to_string())?
310 .max(1);
311 }
312 "--language" => self.language = next_value(&values, &mut index, "--language")?,
313 "--agent-type" => {
314 self.agent_type = Some(next_value(&values, &mut index, "--agent-type")?)
315 .filter(|value| !value.trim().is_empty())
316 }
317 "--temperature" => {
318 let raw = next_value(&values, &mut index, "--temperature")?;
319 self.temperature = Some(parse_temperature(&raw)?);
320 }
321 "--top-p" => {
322 let raw = next_value(&values, &mut index, "--top-p")?;
323 self.top_p = Some(parse_top_p(&raw)?);
324 }
325 "--max-tokens" => {
326 let raw = next_value(&values, &mut index, "--max-tokens")?;
327 self.max_tokens = Some(parse_positive_u32(&raw, "--max-tokens")?);
328 }
329 "--verbose" => self.verbose = true,
330 "--help" | "-h" => return Err(help_text()),
331 other => return Err(format!("unknown argument: {other}\n\n{}", help_text())),
332 }
333 }
334 Ok(())
335 }
336
337 fn finish(self) -> Result<CliArgs, String> {
338 let Some(prompt) = self.prompt.filter(|value| !value.trim().is_empty()) else {
339 return Err(format!("--prompt is required\n\n{}", help_text()));
340 };
341 let model_settings =
342 if self.temperature.is_some() || self.top_p.is_some() || self.max_tokens.is_some() {
343 let settings = ModelSettings {
344 temperature: self.temperature,
345 top_p: self.top_p,
346 max_tokens: self.max_tokens,
347 ..ModelSettings::default()
348 };
349 settings.validate()?;
350 Some(settings)
351 } else {
352 None
353 };
354
355 Ok(CliArgs {
356 prompt,
357 backend: self.backend,
358 model: self.model,
359 settings_file: self.settings_file,
360 workspace: self.workspace,
361 max_cycles: self.max_cycles,
362 language: self.language,
363 agent_type: self.agent_type,
364 model_settings,
365 verbose: self.verbose,
366 })
367 }
368}
369
370fn normalize_equals_arguments(values: Vec<String>) -> Vec<String> {
371 let mut normalized = Vec::with_capacity(values.len());
372 for value in values {
373 let Some((flag, argument)) = value.split_once('=') else {
374 normalized.push(value);
375 continue;
376 };
377 if value_flag(flag) {
378 normalized.push(flag.to_string());
379 normalized.push(argument.to_string());
380 } else {
381 normalized.push(value);
382 }
383 }
384 normalized
385}
386
387fn next_prompt(values: &[String], index: &mut usize) -> Result<String, String> {
388 let start = *index;
389 while *index < values.len() && !cli_flag(&values[*index]) {
390 *index += 1;
391 }
392 if *index == start {
393 return Err("--prompt requires a value".to_string());
394 }
395 Ok(values[start..*index].join(" "))
396}
397
398fn cli_flag(value: &str) -> bool {
399 matches!(value, "--verbose" | "--help" | "-h") || value_flag(value) || value.starts_with("--")
400}
401
402fn value_flag(value: &str) -> bool {
403 matches!(
404 value,
405 "--listen"
406 | "--settings"
407 | "--timeout-seconds"
408 | "--prompt"
409 | "--backend"
410 | "--model"
411 | "--settings-file"
412 | "--workspace"
413 | "--max-cycles"
414 | "--language"
415 | "--agent-type"
416 | "--temperature"
417 | "--top-p"
418 | "--max-tokens"
419 )
420}
421
422fn parse_temperature(value: &str) -> Result<f64, String> {
423 let parsed = parse_f64(value, "--temperature")?;
424 if parsed < 0.0 {
425 return Err("--temperature must be a finite number at least 0".to_string());
426 }
427 Ok(parsed)
428}
429
430fn parse_top_p(value: &str) -> Result<f64, String> {
431 let parsed = parse_f64(value, "--top-p")?;
432 if !(0.0..=1.0).contains(&parsed) {
433 return Err("--top-p must be a finite number between 0 and 1".to_string());
434 }
435 Ok(parsed)
436}
437
438fn parse_f64(value: &str, flag: &str) -> Result<f64, String> {
439 let parsed = value
440 .parse::<f64>()
441 .map_err(|_| format!("{flag} must be a finite number"))?;
442 if !parsed.is_finite() {
443 return Err(format!("{flag} must be a finite number"));
444 }
445 Ok(parsed)
446}
447
448fn parse_positive_f64(value: &str, flag: &str) -> Result<f64, String> {
449 let parsed = value
450 .parse::<f64>()
451 .map_err(|_| format!("{flag} must be a finite positive number"))?;
452 if !parsed.is_finite() || parsed <= 0.0 {
453 return Err(format!("{flag} must be a finite positive number"));
454 }
455 Ok(parsed)
456}
457
458fn parse_positive_u32(value: &str, flag: &str) -> Result<u32, String> {
459 let parsed = value
460 .parse::<i128>()
461 .map_err(|_| format!("{flag} must be an integer"))?;
462 if !(1..=i128::from(u32::MAX)).contains(&parsed) {
463 return Err(format!("{flag} must be between 1 and {}", u32::MAX));
464 }
465 Ok(parsed as u32)
466}
467
468fn next_value(values: &[String], index: &mut usize, flag: &str) -> Result<String, String> {
469 let Some(value) = values.get(*index) else {
470 return Err(format!("{flag} requires a value"));
471 };
472 if cli_flag(value) {
473 return Err(format!("{flag} requires a value"));
474 }
475 *index += 1;
476 Ok(value.clone())
477}
478
479pub(crate) fn help_text() -> String {
480 [
481 "Run a vv-agent task against a configured LLM endpoint.",
482 "",
483 "Required:",
484 " --prompt <text>",
485 "",
486 "Options:",
487 " --backend <key> Provider backend key in LLM_SETTINGS (default: moonshot)",
488 " --model <key> Model key in provider models (default: kimi-k3)",
489 " --settings-file <path> Path to local settings (default: VV_AGENT_LOCAL_SETTINGS or local_settings.json)",
490 " --workspace <path> Workspace directory (default: ./workspace)",
491 " --max-cycles <n> Max runtime cycles (default: 80)",
492 " --language <locale> System prompt language (default: zh-CN)",
493 " --agent-type <type> Agent type, e.g. computer",
494 " --temperature <n> Model sampling temperature",
495 " --top-p <n> Model nucleus sampling threshold",
496 " --max-tokens <n> Maximum generated tokens",
497 " --verbose Show per-cycle runtime logs",
498 "",
499 "App Server:",
500 " app-server --listen stdio --settings <path> --backend <key> --model <key>",
501 " [--timeout-seconds <seconds>]",
502 " app-server schema --out <dir>",
503 " app-server generate-ts --out <dir>",
504 ]
505 .join("\n")
506}