Skip to main content

a_agent/cli/
args.rs

1use std::fmt;
2use std::path::Path;
3
4pub const HELP: &str = r#"a - fast terminal coding agent
5
6Usage:
7  a [OPTIONS] [FILES...] [PROMPT]
8
9Options:
10  -1, --one-turn       Exit after one complete user/tool/model turn
11  -r, --resume         Resume the latest session for the current directory
12      --session ID     Resume a specific session
13      --install-fish   Install Fish shell integration
14  -h, --help           Show help
15  -V, --version        Show version
16"#;
17
18#[derive(Debug, Clone, Default, PartialEq, Eq)]
19pub struct CliArgs {
20    pub one_turn: bool,
21    pub resume: bool,
22    pub resume_session_id: Option<String>,
23    pub files: Vec<String>,
24    pub prompt: Option<String>,
25    pub help: bool,
26    pub version: bool,
27    pub install_fish: bool,
28    pub fish_ai: bool,
29    pub fish_session_key: Option<String>,
30    pub shell_record: Option<ShellRecordArgs>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ShellRecordArgs {
35    pub cwd: String,
36    pub command: String,
37    pub exit_code: Option<i32>,
38    pub started_at: i64,
39    pub duration_ms: Option<i64>,
40    pub pipe_status: Option<String>,
41    pub fish_session_key: Option<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct CliError(String);
46
47impl fmt::Display for CliError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        f.write_str(&self.0)
50    }
51}
52
53impl std::error::Error for CliError {}
54
55pub fn parse_args(args: impl IntoIterator<Item = String>) -> Result<CliArgs, CliError> {
56    parse_args_with(args, |value| Path::new(value).exists())
57}
58
59pub fn parse_args_with(
60    args: impl IntoIterator<Item = String>,
61    is_path: impl Fn(&str) -> bool,
62) -> Result<CliArgs, CliError> {
63    let args = args.into_iter().collect::<Vec<_>>();
64    if args.first().is_some_and(|value| value == "__record-shell") {
65        return parse_shell_record(&args[1..]);
66    }
67    let mut parsed = CliArgs::default();
68    let mut positional = Vec::new();
69    let mut iter = args.into_iter().peekable();
70    let mut options = true;
71
72    while let Some(arg) = iter.next() {
73        if options && arg == "--" {
74            options = false;
75            continue;
76        }
77        if options {
78            match arg.as_str() {
79                "-1" | "--one-turn" => parsed.one_turn = true,
80                "-r" | "--resume" => parsed.resume = true,
81                "-h" | "--help" => parsed.help = true,
82                "-V" | "--version" => parsed.version = true,
83                "--install-fish" => parsed.install_fish = true,
84                "--fish-ai" => parsed.fish_ai = true,
85                "--fish-session-key" => {
86                    parsed.fish_session_key = Some(
87                        iter.next()
88                            .filter(|value| !value.is_empty())
89                            .ok_or_else(|| {
90                                CliError("--fish-session-key requires a value".into())
91                            })?,
92                    );
93                }
94                "--session" => {
95                    parsed.resume = true;
96                    parsed.resume_session_id = Some(
97                        iter.next()
98                            .ok_or_else(|| CliError("--session requires an ID".into()))?,
99                    );
100                }
101                value if value.starts_with('-') => {
102                    return Err(CliError(format!("unknown option: {value}")));
103                }
104                _ => positional.push(arg),
105            }
106        } else {
107            positional.push(arg);
108        }
109    }
110
111    if parsed.resume
112        && parsed.resume_session_id.is_none()
113        && positional
114            .first()
115            .is_some_and(|value| value.starts_with("a_"))
116    {
117        parsed.resume_session_id = Some(positional.remove(0));
118    }
119
120    let file_count = positional.iter().take_while(|value| is_path(value)).count();
121    parsed.files.extend(positional.drain(..file_count));
122    if !positional.is_empty() {
123        parsed.prompt = Some(positional.join(" "));
124    }
125    Ok(parsed)
126}
127
128fn parse_shell_record(args: &[String]) -> Result<CliArgs, CliError> {
129    let mut cwd = None;
130    let mut command = None;
131    let mut exit_code = None;
132    let mut started_at = None;
133    let mut duration_ms = None;
134    let mut pipe_status = None;
135    let mut fish_session_key = None;
136    let mut index = 0;
137    while index < args.len() {
138        let key = &args[index];
139        let value = args
140            .get(index + 1)
141            .ok_or_else(|| CliError(format!("{key} requires a value")))?;
142        match key.as_str() {
143            "--cwd" => cwd = Some(value.clone()),
144            "--command" => command = Some(value.clone()),
145            "--exit-code" => {
146                exit_code = Some(
147                    value
148                        .parse()
149                        .map_err(|_| CliError("--exit-code must be an integer".into()))?,
150                )
151            }
152            "--started-at" => {
153                started_at = Some(
154                    value
155                        .parse()
156                        .map_err(|_| CliError("--started-at must be an integer".into()))?,
157                )
158            }
159            "--duration-ms" => {
160                duration_ms = Some(
161                    value
162                        .parse()
163                        .map_err(|_| CliError("--duration-ms must be an integer".into()))?,
164                )
165            }
166            "--pipe-status" => pipe_status = Some(value.clone()),
167            "--fish-session-key" => fish_session_key = Some(value.clone()),
168            _ => return Err(CliError(format!("unknown shell record option: {key}"))),
169        }
170        index += 2;
171    }
172    Ok(CliArgs {
173        shell_record: Some(ShellRecordArgs {
174            cwd: cwd.ok_or_else(|| CliError("__record-shell requires --cwd".into()))?,
175            command: command.ok_or_else(|| CliError("__record-shell requires --command".into()))?,
176            exit_code,
177            started_at: started_at
178                .ok_or_else(|| CliError("__record-shell requires --started-at".into()))?,
179            duration_ms,
180            pipe_status,
181            fish_session_key,
182        }),
183        ..CliArgs::default()
184    })
185}