1use clap::{ArgAction, Args, Parser, Subcommand};
2use mobius::backend::model::provider::HostedWebSearch;
3
4use super::*;
5
6#[derive(Debug, Parser)]
8#[command(
9 name = "mobius-gateway",
10 version,
11 propagate_version = true,
12 about = "Run and configure a möbius gateway"
13)]
14pub struct GatewayCli {
15 #[arg(long, global = true, value_name = "PATH")]
17 state_dir: Option<PathBuf>,
18
19 #[command(subcommand)]
20 command: Option<GatewaySubcommand>,
21}
22
23#[derive(Debug)]
25pub enum FrontendCommand {
26 Init(PathBuf),
28 Dashboard(PathBuf),
30 Provider(PathBuf),
32}
33
34#[derive(Debug, Subcommand)]
35enum GatewaySubcommand {
36 Provider,
38 Init(InitArgs),
40 Bootstrap,
42 ResetBotDefaults,
44 PairingCode {
46 #[arg(long, required = true)]
48 json: bool,
49 },
50 RegisterProvider(RegisterProviderArgs),
52 ClearProviderCredential {
54 #[arg(long)]
55 instance: String,
56 },
57 Connect(ConnectArgs),
59 Serve(ServeArgs),
61 #[command(name = "__serve", hide = true)]
62 ServeChild,
63 #[cfg(target_os = "macos")]
65 MenuBar,
66 #[cfg(target_os = "macos")]
67 #[command(name = "__menu-bar-connect", hide = true)]
68 MenuBarConnect,
69 Exit,
71}
72
73#[derive(Debug, Args)]
74struct InitArgs {
75 #[arg(long, value_name = "ADDR")]
77 listen: Option<SocketAddr>,
78
79 #[arg(
81 long = "tls-cert",
82 value_name = "PATH",
83 requires = "private_key",
84 conflicts_with_all = ["cloudflare_hostname", "cloudflare_token_file"]
85 )]
86 certificate: Option<PathBuf>,
87
88 #[arg(
90 long = "tls-key",
91 value_name = "PATH",
92 requires = "certificate",
93 conflicts_with_all = ["cloudflare_hostname", "cloudflare_token_file"]
94 )]
95 private_key: Option<PathBuf>,
96
97 #[arg(
99 long,
100 value_name = "HOST",
101 requires = "cloudflare_token_file",
102 conflicts_with_all = ["certificate", "private_key"]
103 )]
104 cloudflare_hostname: Option<String>,
105
106 #[arg(
108 long,
109 value_name = "PATH",
110 requires = "cloudflare_hostname",
111 conflicts_with_all = ["certificate", "private_key"]
112 )]
113 cloudflare_token_file: Option<PathBuf>,
114}
115
116impl InitArgs {
117 fn is_interactive(&self) -> bool {
118 self.listen.is_none()
119 && self.certificate.is_none()
120 && self.private_key.is_none()
121 && self.cloudflare_hostname.is_none()
122 && self.cloudflare_token_file.is_none()
123 }
124}
125
126#[derive(Debug, Args)]
127struct RegisterProviderArgs {
128 #[arg(long, value_name = "ID")]
130 provider: String,
131
132 #[arg(long, value_name = "ID")]
134 instance: Option<String>,
135
136 #[arg(long, value_name = "TEXT")]
138 label: Option<String>,
139
140 #[arg(long, value_name = "ID")]
142 model: String,
143
144 #[arg(
146 long,
147 value_name = "CSV",
148 value_delimiter = ',',
149 action = ArgAction::Set
150 )]
151 reasoning_efforts: Vec<String>,
152
153 #[arg(long, value_name = "MODE", default_value = "off")]
155 web_search: HostedWebSearch,
156
157 #[arg(long, value_name = "URL")]
159 base_url: Option<String>,
160
161 #[arg(long, conflicts_with = "credential_stdin")]
163 credentialless: bool,
164
165 #[arg(long)]
167 credential_stdin: bool,
168
169 #[arg(long, value_name = "TIMESTAMP", requires = "credential_stdin")]
171 credential_expires_at: Option<u64>,
172}
173
174#[derive(Debug, Args)]
175struct ConnectArgs {
176 #[arg(long, value_name = "ENDPOINT")]
178 endpoint: Option<Endpoint>,
179}
180
181#[derive(Debug, Args)]
182struct ServeArgs {
183 #[arg(long)]
185 background: bool,
186}
187
188#[derive(Debug)]
189pub(super) enum Command {
190 Init(InitOptions),
191 Bootstrap {
192 state_dir: PathBuf,
193 },
194 ResetBotDefaults {
195 state_dir: PathBuf,
196 },
197 PairingCode {
198 state_dir: PathBuf,
199 },
200 RegisterProvider(RegisterProviderOptions),
201 ClearProviderCredential {
202 state_dir: PathBuf,
203 instance: String,
204 },
205 Connect(ConnectOptions),
206 Serve {
207 state_dir: PathBuf,
208 background: bool,
209 },
210 ServeChild {
211 state_dir: PathBuf,
212 },
213 #[cfg(target_os = "macos")]
214 MenuBar {
215 state_dir: PathBuf,
216 },
217 #[cfg(target_os = "macos")]
218 MenuBarConnect {
219 state_dir: PathBuf,
220 },
221 Exit {
222 state_dir: PathBuf,
223 },
224}
225
226#[derive(Debug)]
227pub(super) struct InitOptions {
228 pub(super) state_dir: PathBuf,
229 pub(super) listen: SocketAddr,
230 pub(super) tls: Option<TlsConfig>,
231 pub(super) cloudflare: Option<CloudflareInit>,
232}
233
234pub(super) enum CloudflareInit {
235 Quick,
236 Named { hostname: String, token: String },
237}
238
239impl std::fmt::Debug for CloudflareInit {
240 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241 match self {
242 Self::Quick => formatter.write_str("CloudflareInit::Quick"),
243 Self::Named { hostname, .. } => formatter
244 .debug_struct("CloudflareInit::Named")
245 .field("hostname", hostname)
246 .field("token", &"[redacted]")
247 .finish(),
248 }
249 }
250}
251
252#[derive(Debug)]
253pub(super) struct ConnectOptions {
254 pub(super) state_dir: PathBuf,
255 pub(super) endpoint: Option<Endpoint>,
256}
257
258#[derive(Debug)]
259pub(super) struct RegisterProviderOptions {
260 pub(super) state_dir: PathBuf,
261 pub(super) provider: String,
262 pub(super) instance: Option<String>,
263 pub(super) label: Option<String>,
264 pub(super) model: String,
265 pub(super) reasoning_efforts: Vec<String>,
266 pub(super) web_search: HostedWebSearch,
267 pub(super) base_url: Option<String>,
268 pub(super) credentialless: bool,
269 pub(super) credential_stdin: bool,
270 pub(super) credential_expires_at: Option<u64>,
271}
272
273impl GatewayCli {
274 pub fn frontend_command(&self) -> Result<Option<FrontendCommand>> {
276 let command = match &self.command {
277 None => FrontendCommand::Dashboard(self.resolved_state_dir()?),
278 Some(GatewaySubcommand::Provider) => {
279 FrontendCommand::Provider(self.resolved_state_dir()?)
280 }
281 Some(GatewaySubcommand::Init(arguments)) if arguments.is_interactive() => {
282 FrontendCommand::Init(self.resolved_state_dir()?)
283 }
284 _ => return Ok(None),
285 };
286 Ok(Some(command))
287 }
288
289 fn resolved_state_dir(&self) -> Result<PathBuf> {
290 self.state_dir.clone().map_or_else(state_dir, Ok)
291 }
292
293 pub(super) fn into_command(self) -> Result<Command> {
294 let state_dir = self.state_dir.map_or_else(state_dir, Ok)?;
295 match self.command {
296 Some(GatewaySubcommand::Init(arguments)) => {
297 parse_init(state_dir, arguments).map(Command::Init)
298 }
299 Some(GatewaySubcommand::Bootstrap) => Ok(Command::Bootstrap { state_dir }),
300 Some(GatewaySubcommand::ResetBotDefaults) => {
301 Ok(Command::ResetBotDefaults { state_dir })
302 }
303 Some(GatewaySubcommand::PairingCode { json: _ }) => {
304 Ok(Command::PairingCode { state_dir })
305 }
306 Some(GatewaySubcommand::ClearProviderCredential { instance }) => {
307 Ok(Command::ClearProviderCredential {
308 state_dir,
309 instance,
310 })
311 }
312 Some(GatewaySubcommand::RegisterProvider(arguments)) => {
313 Ok(Command::RegisterProvider(RegisterProviderOptions {
314 state_dir,
315 provider: arguments.provider,
316 instance: arguments.instance,
317 label: arguments.label,
318 model: arguments.model,
319 reasoning_efforts: arguments.reasoning_efforts,
320 web_search: arguments.web_search,
321 base_url: arguments.base_url,
322 credentialless: arguments.credentialless,
323 credential_stdin: arguments.credential_stdin,
324 credential_expires_at: arguments.credential_expires_at,
325 }))
326 }
327 Some(GatewaySubcommand::Connect(arguments)) => Ok(Command::Connect(ConnectOptions {
328 state_dir,
329 endpoint: arguments.endpoint,
330 })),
331 Some(GatewaySubcommand::Serve(arguments)) => Ok(Command::Serve {
332 state_dir,
333 background: arguments.background,
334 }),
335 Some(GatewaySubcommand::ServeChild) => Ok(Command::ServeChild { state_dir }),
336 #[cfg(target_os = "macos")]
337 Some(GatewaySubcommand::MenuBar) => Ok(Command::MenuBar { state_dir }),
338 #[cfg(target_os = "macos")]
339 Some(GatewaySubcommand::MenuBarConnect) => Ok(Command::MenuBarConnect { state_dir }),
340 Some(GatewaySubcommand::Exit) => Ok(Command::Exit { state_dir }),
341 None | Some(GatewaySubcommand::Provider) => Err(Error::Config(
342 "an executable gateway command is required".into(),
343 )),
344 }
345 }
346}
347
348pub(super) fn parse_cli(arguments: Vec<OsString>) -> std::result::Result<GatewayCli, clap::Error> {
349 GatewayCli::try_parse_from(std::iter::once(OsString::from("mobius-gateway")).chain(arguments))
350}
351
352#[cfg(test)]
353pub(super) fn parse(arguments: Vec<OsString>) -> Result<Command> {
354 parse_cli(arguments)
355 .map_err(|error| Error::Config(error.to_string()))?
356 .into_command()
357}
358
359fn parse_init(state_dir: PathBuf, arguments: InitArgs) -> Result<InitOptions> {
360 let tls = match (arguments.certificate, arguments.private_key) {
361 (Some(certificate), Some(private_key)) => Some(TlsConfig {
362 certificate: std::fs::canonicalize(certificate)?,
363 private_key: std::fs::canonicalize(private_key)?,
364 }),
365 (None, None) => None,
366 _ => {
367 return Err(Error::Config(
368 "--tls-cert and --tls-key must be supplied together".into(),
369 ));
370 }
371 };
372 let cloudflare = match (
373 arguments.cloudflare_hostname,
374 arguments.cloudflare_token_file,
375 ) {
376 (Some(hostname), Some(path)) => Some(CloudflareInit::Named {
377 hostname,
378 token: load_cloudflare_token(&path)?,
379 }),
380 (None, None) => None,
381 _ => {
382 return Err(Error::Config(
383 "--cloudflare-hostname and --cloudflare-token-file must be supplied together"
384 .into(),
385 ));
386 }
387 };
388 Ok(InitOptions {
389 state_dir,
390 listen: arguments.listen.unwrap_or(DEFAULT_LISTEN),
391 tls,
392 cloudflare,
393 })
394}