1use clap::{
2 Args, Parser, Subcommand, ValueEnum,
3 builder::{Styles, styling::AnsiColor},
4};
5
6const fn brand_styles() -> Styles {
7 Styles::styled()
8 .header(AnsiColor::BrightCyan.on_default().bold())
9 .usage(AnsiColor::BrightBlue.on_default().bold())
10 .literal(AnsiColor::BrightYellow.on_default().bold())
11 .placeholder(AnsiColor::BrightMagenta.on_default())
12 .valid(AnsiColor::BrightGreen.on_default())
13}
14
15pub const DEFAULT_DATABASE_URL: &str = "sqlite://./agentic_api.db";
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
18pub enum Harness {
19 Codex,
20 Claude,
21}
22
23#[derive(Debug, Parser)]
24#[command(
25 name = "agentic",
26 about = "Agentic API — local agent gateway for Claude Code and Codex",
27 version,
28 styles = brand_styles(),
29)]
30pub struct Cli {
31 #[command(subcommand)]
32 pub command: Command,
33}
34
35#[derive(Debug, Subcommand)]
36pub enum Command {
37 Run {
39 #[command(subcommand)]
40 harness: HarnessCommand,
41 },
42 Serve(ServeOptions),
44 Validate(ValidateOptions),
46}
47
48#[derive(Debug, Subcommand)]
49pub enum HarnessCommand {
50 Codex(HarnessOptions),
52 Claude(HarnessOptions),
54}
55
56#[derive(Args, Clone, Debug)]
57pub struct HarnessOptions {
58 #[command(flatten)]
59 pub source: SourceOptions,
60
61 #[command(flatten)]
62 pub common: CommonOptions,
63
64 #[arg(last = true, allow_hyphen_values = true)]
66 pub harness_args: Vec<String>,
67}
68
69#[derive(Args, Clone, Debug)]
70pub struct ServeOptions {
71 #[command(flatten)]
72 pub source: SourceOptions,
73
74 #[command(flatten)]
75 pub common: CommonOptions,
76}
77
78#[derive(Args, Clone, Debug)]
79pub struct ValidateOptions {
80 #[command(flatten)]
81 pub source: SourceOptions,
82
83 #[command(flatten)]
84 pub common: CommonOptions,
85
86 #[arg(long, value_enum)]
88 pub harness: Option<Harness>,
89}
90
91#[derive(Args, Clone, Debug)]
92pub struct SourceOptions {
93 #[arg(long, required_unless_present = "model", value_parser = parse_upstream_url)]
95 pub upstream: Option<String>,
96
97 #[arg(long, required_unless_present = "upstream")]
100 pub model: Option<String>,
101
102 #[arg(long, default_value_t = 8000)]
104 pub llm_port: u16,
105}
106
107#[derive(Args, Clone, Debug)]
108#[allow(clippy::struct_excessive_bools)]
109pub struct CommonOptions {
110 #[arg(long, default_value = "127.0.0.1", env = "GATEWAY_HOST")]
112 pub gateway_host: String,
113
114 #[arg(long, default_value_t = 3000, env = "GATEWAY_PORT")]
116 pub gateway_port: u16,
117
118 #[arg(long, default_value = DEFAULT_DATABASE_URL, env = "DATABASE_URL", hide_env_values = true)]
120 pub database_url: String,
121
122 #[arg(long, env = "OPENAI_API_KEY", hide_env_values = true)]
124 pub api_key: Option<String>,
125
126 #[arg(long, default_value_t = false)]
128 pub skip_llm_ready_check: bool,
129
130 #[arg(long, default_value_t = 600.0, value_parser = parse_timeout_seconds)]
132 pub llm_ready_timeout_s: f64,
133
134 #[arg(long, default_value_t = 2.0, value_parser = parse_interval_seconds)]
136 pub llm_ready_interval_s: f64,
137
138 #[arg(long)]
140 pub quiet: bool,
141
142 #[arg(long)]
144 pub yolo: bool,
145
146 #[arg(long)]
148 pub no_color: bool,
149}
150
151fn parse_upstream_url(value: &str) -> Result<String, String> {
152 let parsed = url::Url::parse(value).map_err(|error| format!("invalid upstream URL `{value}`: {error}"))?;
153 if !matches!(parsed.scheme(), "http" | "https") {
154 return Err(format!(
155 "invalid upstream URL `{value}`: expected an http:// or https:// base URL"
156 ));
157 }
158 if parsed.host_str().is_none_or(str::is_empty) {
159 return Err(format!("invalid upstream URL `{value}`: missing host"));
160 }
161 if parsed.query().is_some() || parsed.fragment().is_some() {
162 return Err(format!(
163 "invalid upstream URL `{value}`: query strings and fragments are not supported; pass a base URL such as http://host:port"
164 ));
165 }
166 Ok(value.trim_end_matches('/').to_owned())
167}
168
169fn parse_timeout_seconds(value: &str) -> Result<f64, String> {
170 let value = value
171 .parse::<f64>()
172 .map_err(|error| format!("invalid timeout in seconds: {error}"))?;
173 if value.is_finite() && value >= 0.0 {
174 Ok(value)
175 } else {
176 Err("timeout must be a finite, non-negative number of seconds".to_owned())
177 }
178}
179
180fn parse_interval_seconds(value: &str) -> Result<f64, String> {
181 let value = value
182 .parse::<f64>()
183 .map_err(|error| format!("invalid interval in seconds: {error}"))?;
184 if value.is_finite() && value > 0.0 {
185 Ok(value)
186 } else {
187 Err("interval must be a finite, positive number of seconds".to_owned())
188 }
189}
190
191impl Default for CommonOptions {
192 fn default() -> Self {
193 Self {
194 gateway_host: "127.0.0.1".to_owned(),
195 gateway_port: 3000,
196 database_url: DEFAULT_DATABASE_URL.to_owned(),
197 api_key: None,
198 skip_llm_ready_check: false,
199 llm_ready_timeout_s: 600.0,
200 llm_ready_interval_s: 2.0,
201 quiet: false,
202 yolo: false,
203 no_color: false,
204 }
205 }
206}
207
208impl HarnessCommand {
209 #[must_use]
210 pub fn harness(&self) -> Harness {
211 match self {
212 Self::Codex(_) => Harness::Codex,
213 Self::Claude(_) => Harness::Claude,
214 }
215 }
216
217 #[must_use]
218 pub fn options(&self) -> &HarnessOptions {
219 match self {
220 Self::Codex(options) | Self::Claude(options) => options,
221 }
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use clap::Parser;
228
229 use super::{Cli, Command, DEFAULT_DATABASE_URL, HarnessCommand};
230
231 #[test]
232 fn run_codex_uses_sqlite_by_default_and_preserves_arguments() {
233 let cli = Cli::try_parse_from([
234 "agentic",
235 "run",
236 "codex",
237 "--model",
238 "Qwen/test",
239 "--",
240 "exec",
241 "inspect this repo",
242 ])
243 .expect("valid CLI");
244
245 let Command::Run { harness } = cli.command else {
246 panic!("expected run command");
247 };
248 assert!(matches!(harness, HarnessCommand::Codex(_)));
249 let options = harness.options();
250 assert_eq!(options.source.model.as_deref(), Some("Qwen/test"));
251 assert_eq!(options.common.database_url, DEFAULT_DATABASE_URL);
252 assert_eq!(options.harness_args, ["exec", "inspect this repo"]);
253 }
254
255 #[test]
256 fn run_claude_accepts_an_explicit_postgres_database() {
257 let cli = Cli::try_parse_from([
258 "agentic",
259 "run",
260 "claude",
261 "--upstream",
262 "http://127.0.0.1:8000",
263 "--database-url",
264 "postgresql://user:secret@localhost/agentic",
265 ])
266 .expect("valid CLI");
267
268 let Command::Run { harness } = cli.command else {
269 panic!("expected run command");
270 };
271 assert!(matches!(harness, HarnessCommand::Claude(_)));
272 let options = harness.options();
273 assert_eq!(options.source.upstream.as_deref(), Some("http://127.0.0.1:8000"));
274 assert_eq!(
275 options.common.database_url,
276 "postgresql://user:secret@localhost/agentic"
277 );
278 }
279
280 #[test]
281 fn run_accepts_upstream_with_an_explicit_model_name() {
282 let result = Cli::try_parse_from([
283 "agentic",
284 "run",
285 "codex",
286 "--model",
287 "Qwen/test",
288 "--upstream",
289 "http://127.0.0.1:8000",
290 ]);
291
292 let cli = result.expect("valid CLI");
293 let Command::Run { harness } = cli.command else {
294 panic!("expected run command");
295 };
296 assert_eq!(harness.options().source.model.as_deref(), Some("Qwen/test"));
297 }
298
299 #[test]
300 fn run_rejects_malformed_upstream_urls() {
301 for upstream in [
302 "http//127.0.0.1:8000",
303 "127.0.0.1:8000",
304 "ftp://127.0.0.1:8000",
305 "http://",
306 ] {
307 let error = Cli::try_parse_from(["agentic", "run", "claude", "--upstream", upstream])
308 .expect_err("malformed upstream URL should be rejected");
309 assert!(
310 error.to_string().contains("invalid upstream URL"),
311 "unexpected error for {upstream}: {error}"
312 );
313 }
314 }
315
316 #[test]
317 fn run_normalizes_trailing_slash_on_upstream() {
318 let cli = Cli::try_parse_from(["agentic", "run", "claude", "--upstream", "http://127.0.0.1:8000/"])
319 .expect("valid CLI");
320 let Command::Run { harness } = cli.command else {
321 panic!("expected run command");
322 };
323 assert_eq!(
324 harness.options().source.upstream.as_deref(),
325 Some("http://127.0.0.1:8000")
326 );
327 }
328
329 #[test]
330 fn run_accepts_yolo_mode() {
331 let cli =
332 Cli::try_parse_from(["agentic", "run", "claude", "--model", "Qwen/test", "--yolo"]).expect("valid CLI");
333
334 let Command::Run { harness } = cli.command else {
335 panic!("expected run command");
336 };
337 assert!(harness.options().common.yolo);
338 }
339}