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