Skip to main content

agentic_server/
agentic_cli.rs

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    /// Start Agentic API and launch a coding harness
38    Run {
39        #[command(subcommand)]
40        harness: HarnessCommand,
41    },
42    /// Start Agentic API without launching a harness
43    Serve(ServeOptions),
44    /// Validate the local Agentic API session prerequisites
45    Validate(ValidateOptions),
46}
47
48#[derive(Debug, Subcommand)]
49pub enum HarnessCommand {
50    /// Launch Codex with an isolated provider configuration
51    Codex(HarnessOptions),
52    /// Launch Claude Code with an isolated gateway environment
53    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    /// Arguments forwarded to the selected harness after `--`
65    #[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    /// Also verify a harness binary without launching it
87    #[arg(long, value_enum)]
88    pub harness: Option<Harness>,
89}
90
91#[derive(Args, Clone, Debug)]
92pub struct SourceOptions {
93    /// Connect to an already-running OpenAI-compatible upstream (`http://` or `https://` base URL)
94    #[arg(long, required_unless_present = "model", value_parser = parse_upstream_url)]
95    pub upstream: Option<String>,
96
97    /// Model to start with vLLM, or the model name to use with `--upstream`.
98    /// When omitted alongside `--upstream`, the first model served by the upstream is used.
99    #[arg(long, required_unless_present = "upstream")]
100    pub model: Option<String>,
101
102    /// vLLM port when starting a model
103    #[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    /// Gateway bind host
111    #[arg(long, default_value = "127.0.0.1", env = "GATEWAY_HOST")]
112    pub gateway_host: String,
113
114    /// Gateway bind port
115    #[arg(long, default_value_t = 3000, env = "GATEWAY_PORT")]
116    pub gateway_port: u16,
117
118    /// `SQLite` or `PostgreSQL` storage URL
119    #[arg(long, default_value = DEFAULT_DATABASE_URL, env = "DATABASE_URL", hide_env_values = true)]
120    pub database_url: String,
121
122    /// API key forwarded to the gateway and harness when configured
123    #[arg(long, env = "OPENAI_API_KEY", hide_env_values = true)]
124    pub api_key: Option<String>,
125
126    /// Skip the upstream readiness probe
127    #[arg(long, default_value_t = false)]
128    pub skip_llm_ready_check: bool,
129
130    /// Upstream readiness timeout in seconds
131    #[arg(long, default_value_t = 600.0, value_parser = parse_timeout_seconds)]
132    pub llm_ready_timeout_s: f64,
133
134    /// Upstream readiness poll interval in seconds
135    #[arg(long, default_value_t = 2.0, value_parser = parse_interval_seconds)]
136    pub llm_ready_interval_s: f64,
137
138    /// Suppress lifecycle output
139    #[arg(long)]
140    pub quiet: bool,
141
142    /// Skip harness permission prompts and sandbox restrictions
143    #[arg(long)]
144    pub yolo: bool,
145
146    /// Disable ANSI color output
147    #[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}