Skip to main content

vv_agent/cli/
args.rs

1use std::env;
2use std::path::PathBuf;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct CliArgs {
6    pub prompt: String,
7    pub backend: String,
8    pub model: String,
9    pub settings_file: PathBuf,
10    pub workspace: PathBuf,
11    pub max_cycles: u32,
12    pub language: String,
13    pub agent_type: Option<String>,
14    pub verbose: bool,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum CliCommand {
19    Run(CliArgs),
20    AppServer(AppServerCliCommand),
21    Debug(DebugCliCommand),
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum AppServerCliCommand {
26    ListenStdio,
27    GenerateTs { out: PathBuf },
28    GenerateJsonSchema { out: PathBuf },
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum DebugCliCommand {
33    AppServerSendMessage { message: String },
34}
35
36pub fn parse_cli_args_from<I, S>(args: I) -> Result<CliArgs, String>
37where
38    I: IntoIterator<Item = S>,
39    S: Into<String>,
40{
41    let default_settings =
42        env::var("VV_AGENT_LOCAL_SETTINGS").unwrap_or_else(|_| "local_settings.json".to_string());
43    parse_cli_args_from_with_default_settings(args, default_settings)
44}
45
46pub fn parse_cli_command_from<I, S>(args: I) -> Result<CliCommand, String>
47where
48    I: IntoIterator<Item = S>,
49    S: Into<String>,
50{
51    let default_settings =
52        env::var("VV_AGENT_LOCAL_SETTINGS").unwrap_or_else(|_| "local_settings.json".to_string());
53    parse_cli_command_from_with_default_settings(args, default_settings)
54}
55
56pub fn parse_cli_command_from_with_default_settings<I, S>(
57    args: I,
58    default_settings: impl Into<String>,
59) -> Result<CliCommand, String>
60where
61    I: IntoIterator<Item = S>,
62    S: Into<String>,
63{
64    let mut values = args.into_iter().map(Into::into).collect::<Vec<_>>();
65    if !values.is_empty() {
66        values.remove(0);
67    }
68    match values.first().map(String::as_str) {
69        Some("app-server") => parse_app_server_command(&values[1..]).map(CliCommand::AppServer),
70        Some("debug") => parse_debug_command(&values[1..]).map(CliCommand::Debug),
71        _ => parse_cli_args_from_with_default_settings(
72            std::iter::once("vv-agent".to_string()).chain(values),
73            default_settings,
74        )
75        .map(CliCommand::Run),
76    }
77}
78
79pub fn parse_cli_args_from_with_default_settings<I, S>(
80    args: I,
81    default_settings: impl Into<String>,
82) -> Result<CliArgs, String>
83where
84    I: IntoIterator<Item = S>,
85    S: Into<String>,
86{
87    let mut values = args.into_iter().map(Into::into).collect::<Vec<_>>();
88    if !values.is_empty() {
89        values.remove(0);
90    }
91
92    let mut parsed = ParsedCliArgs::with_default_settings(default_settings.into());
93    parsed.consume(values)?;
94    parsed.finish()
95}
96
97fn parse_app_server_command(values: &[String]) -> Result<AppServerCliCommand, String> {
98    match values.first().map(String::as_str) {
99        Some("--listen") => {
100            let listen = values
101                .get(1)
102                .ok_or_else(|| "app-server --listen requires a value".to_string())?;
103            if listen != "stdio" {
104                return Err("only app-server --listen stdio is supported".to_string());
105            }
106            Ok(AppServerCliCommand::ListenStdio)
107        }
108        Some("generate-ts") => Ok(AppServerCliCommand::GenerateTs {
109            out: parse_out_dir(&values[1..], "app-server generate-ts")?,
110        }),
111        Some("generate-json-schema") => Ok(AppServerCliCommand::GenerateJsonSchema {
112            out: parse_out_dir(&values[1..], "app-server generate-json-schema")?,
113        }),
114        _ => Err(format!("unknown app-server command\n\n{}", help_text())),
115    }
116}
117
118fn parse_debug_command(values: &[String]) -> Result<DebugCliCommand, String> {
119    match (
120        values.first().map(String::as_str),
121        values.get(1).map(String::as_str),
122        values.get(2).map(String::as_str),
123    ) {
124        (Some("app-server"), Some("send-message"), Some(message)) => {
125            Ok(DebugCliCommand::AppServerSendMessage {
126                message: message.to_string(),
127            })
128        }
129        _ => Err(format!("unknown debug command\n\n{}", help_text())),
130    }
131}
132
133fn parse_out_dir(values: &[String], command: &str) -> Result<PathBuf, String> {
134    if values.first().map(String::as_str) != Some("--out") {
135        return Err(format!("{command} requires --out <dir>"));
136    }
137    values
138        .get(1)
139        .map(PathBuf::from)
140        .ok_or_else(|| format!("{command} requires --out <dir>"))
141}
142
143struct ParsedCliArgs {
144    prompt: Option<String>,
145    backend: String,
146    model: String,
147    settings_file: PathBuf,
148    workspace: PathBuf,
149    max_cycles: u32,
150    language: String,
151    agent_type: Option<String>,
152    verbose: bool,
153}
154
155impl ParsedCliArgs {
156    fn with_default_settings(default_settings: String) -> Self {
157        Self {
158            prompt: None,
159            backend: "moonshot".to_string(),
160            model: "kimi-k2.6".to_string(),
161            settings_file: PathBuf::from(default_settings),
162            workspace: PathBuf::from("./workspace"),
163            max_cycles: 80,
164            language: "zh-CN".to_string(),
165            agent_type: None,
166            verbose: false,
167        }
168    }
169
170    fn consume(&mut self, values: Vec<String>) -> Result<(), String> {
171        let mut index = 0;
172        while index < values.len() {
173            let flag = &values[index];
174            index += 1;
175            match flag.as_str() {
176                "--prompt" => self.prompt = Some(next_value(&values, &mut index, "--prompt")?),
177                "--backend" => self.backend = next_value(&values, &mut index, "--backend")?,
178                "--model" => self.model = next_value(&values, &mut index, "--model")?,
179                "--settings-file" => {
180                    self.settings_file =
181                        PathBuf::from(next_value(&values, &mut index, "--settings-file")?)
182                }
183                "--workspace" => {
184                    self.workspace = PathBuf::from(next_value(&values, &mut index, "--workspace")?)
185                }
186                "--max-cycles" => {
187                    let raw = next_value(&values, &mut index, "--max-cycles")?;
188                    self.max_cycles = raw
189                        .parse::<u32>()
190                        .map_err(|_| "--max-cycles must be an integer".to_string())?
191                        .max(1);
192                }
193                "--language" => self.language = next_value(&values, &mut index, "--language")?,
194                "--agent-type" => {
195                    self.agent_type = Some(next_value(&values, &mut index, "--agent-type")?)
196                        .filter(|value| !value.trim().is_empty())
197                }
198                "--verbose" => self.verbose = true,
199                "--help" | "-h" => return Err(help_text()),
200                other => return Err(format!("unknown argument: {other}\n\n{}", help_text())),
201            }
202        }
203        Ok(())
204    }
205
206    fn finish(self) -> Result<CliArgs, String> {
207        let Some(prompt) = self.prompt.filter(|value| !value.trim().is_empty()) else {
208            return Err(format!("--prompt is required\n\n{}", help_text()));
209        };
210
211        Ok(CliArgs {
212            prompt,
213            backend: self.backend,
214            model: self.model,
215            settings_file: self.settings_file,
216            workspace: self.workspace,
217            max_cycles: self.max_cycles,
218            language: self.language,
219            agent_type: self.agent_type,
220            verbose: self.verbose,
221        })
222    }
223}
224
225fn next_value(values: &[String], index: &mut usize, flag: &str) -> Result<String, String> {
226    let Some(value) = values.get(*index) else {
227        return Err(format!("{flag} requires a value"));
228    };
229    *index += 1;
230    Ok(value.clone())
231}
232
233pub(crate) fn help_text() -> String {
234    [
235        "Run a vv-agent task against a configured LLM endpoint.",
236        "",
237        "Required:",
238        "  --prompt <text>",
239        "",
240        "Options:",
241        "  --backend <key>        Provider backend key in LLM_SETTINGS (default: moonshot)",
242        "  --model <key>          Model key in provider models (default: kimi-k2.6)",
243        "  --settings-file <path> Path to local settings (default: VV_AGENT_LOCAL_SETTINGS or local_settings.json)",
244        "  --workspace <path>     Workspace directory (default: ./workspace)",
245        "  --max-cycles <n>       Max runtime cycles (default: 80)",
246        "  --language <locale>    System prompt language (default: zh-CN)",
247        "  --agent-type <type>    Agent type, e.g. computer",
248        "  --verbose             Show per-cycle runtime logs",
249    ]
250    .join("\n")
251}