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}