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 PrintDefaultConfig,
38 CheckConfig,
40 ExportComputerResources {
42 #[arg(long, value_name = "PATH")]
43 directory: PathBuf,
44 },
45 Provider,
47 Init(InitArgs),
49 Bootstrap,
51 ResetBotDefaults,
53 SetDesktop {
55 #[arg(long, action = ArgAction::Set, hide_possible_values = true)]
57 enabled: bool,
58 },
59 PairingCode {
61 #[arg(long, required = true)]
63 json: bool,
64 },
65 RegisterProvider(RegisterProviderArgs),
67 ClearProviderCredential {
69 #[arg(long)]
70 instance: String,
71 },
72 Connect(ConnectArgs),
74 Serve(ServeArgs),
76 #[command(name = "__serve", hide = true)]
77 ServeChild,
78 Telemetry {
80 #[command(subcommand)]
81 command: TelemetryCommand,
82 },
83 SetRuntime {
85 #[arg(long)]
86 idle_exit_seconds: Option<u64>,
87 #[arg(long)]
89 storage_limit_bytes: Option<u64>,
90 #[arg(long)]
91 ingress: Option<SocketAddr>,
92 #[arg(long, conflicts_with = "ingress")]
93 clear_ingress: bool,
94 #[arg(long, conflicts_with = "storage_limit_bytes")]
95 clear_storage_limit: bool,
96 },
97 Exit,
99}
100
101#[derive(Debug, Subcommand)]
102pub(super) enum TelemetryCommand {
103 List,
105 Add {
107 #[arg(long)]
108 id: String,
109 #[arg(long)]
110 url: String,
111 #[arg(long, default_value_t = 60)]
112 every_seconds: u32,
113 #[arg(long)]
115 upload_admission: bool,
116 #[arg(long, value_delimiter = ',')]
118 sections: Vec<String>,
119 #[arg(long, value_delimiter = ',')]
120 events: Vec<String>,
121 #[arg(long)]
122 bearer_env: Option<String>,
123 #[arg(long)]
124 bearer_file: Option<String>,
125 #[arg(long = "field")]
126 fields: Vec<String>,
127 },
128 Remove {
130 #[arg(long)]
131 id: String,
132 },
133}
134
135#[derive(Debug, Args)]
136struct InitArgs {
137 #[arg(long, value_name = "ADDR")]
139 listen: Option<SocketAddr>,
140
141 #[arg(
143 long = "tls-cert",
144 value_name = "PATH",
145 requires = "private_key",
146 conflicts_with_all = ["cloudflare_hostname", "cloudflare_token_file"]
147 )]
148 certificate: Option<PathBuf>,
149
150 #[arg(
152 long = "tls-key",
153 value_name = "PATH",
154 requires = "certificate",
155 conflicts_with_all = ["cloudflare_hostname", "cloudflare_token_file"]
156 )]
157 private_key: Option<PathBuf>,
158
159 #[arg(
161 long,
162 value_name = "HOST",
163 requires = "cloudflare_token_file",
164 conflicts_with_all = ["certificate", "private_key"]
165 )]
166 cloudflare_hostname: Option<String>,
167
168 #[arg(
170 long,
171 value_name = "PATH",
172 requires = "cloudflare_hostname",
173 conflicts_with_all = ["certificate", "private_key"]
174 )]
175 cloudflare_token_file: Option<PathBuf>,
176}
177
178impl InitArgs {
179 fn is_interactive(&self) -> bool {
180 self.listen.is_none()
181 && self.certificate.is_none()
182 && self.private_key.is_none()
183 && self.cloudflare_hostname.is_none()
184 && self.cloudflare_token_file.is_none()
185 }
186}
187
188#[derive(Debug, Args)]
189struct RegisterProviderArgs {
190 #[arg(long, value_name = "TIER")]
192 service_tier: Option<String>,
193 #[arg(long, value_name = "ID")]
195 provider: String,
196
197 #[arg(long, value_name = "ID")]
199 instance: Option<String>,
200
201 #[arg(long, value_name = "TEXT")]
203 label: Option<String>,
204
205 #[arg(long, value_name = "ID")]
207 model: String,
208
209 #[arg(
211 long,
212 value_name = "CSV",
213 value_delimiter = ',',
214 action = ArgAction::Set
215 )]
216 reasoning_efforts: Vec<String>,
217
218 #[arg(long, value_name = "MODE", default_value = "off")]
220 web_search: HostedWebSearch,
221
222 #[arg(long, value_name = "URL")]
224 base_url: Option<String>,
225
226 #[arg(long, conflicts_with = "credential_stdin")]
228 credentialless: bool,
229
230 #[arg(long)]
232 credential_stdin: bool,
233
234 #[arg(long, value_name = "TIMESTAMP", requires = "credential_stdin")]
236 credential_expires_at: Option<u64>,
237}
238
239#[derive(Debug, Args)]
240struct ConnectArgs {
241 #[arg(long, value_name = "ENDPOINT")]
243 endpoint: Option<Endpoint>,
244}
245
246#[derive(Debug, Args)]
247struct ServeArgs {
248 #[arg(long)]
250 background: bool,
251}
252
253#[derive(Debug)]
254pub(super) enum Command {
255 PrintDefaultConfig,
256 CheckConfig {
257 state_dir: PathBuf,
258 },
259 ExportComputerResources {
260 directory: PathBuf,
261 },
262 Telemetry {
263 state_dir: PathBuf,
264 command: TelemetryCommand,
265 },
266 SetRuntime {
267 state_dir: PathBuf,
268 idle_exit_seconds: Option<u64>,
269 storage_limit_bytes: Option<u64>,
270 ingress: Option<SocketAddr>,
271 clear_ingress: bool,
272 clear_storage_limit: bool,
273 },
274 Init(InitOptions),
275 Bootstrap {
276 state_dir: PathBuf,
277 },
278 ResetBotDefaults {
279 state_dir: PathBuf,
280 },
281 SetDesktop {
282 state_dir: PathBuf,
283 enabled: bool,
284 },
285 PairingCode {
286 state_dir: PathBuf,
287 },
288 RegisterProvider(RegisterProviderOptions),
289 ClearProviderCredential {
290 state_dir: PathBuf,
291 instance: String,
292 },
293 Connect(ConnectOptions),
294 Serve {
295 state_dir: PathBuf,
296 background: bool,
297 },
298 ServeChild {
299 state_dir: PathBuf,
300 },
301 Exit {
302 state_dir: PathBuf,
303 },
304}
305
306#[derive(Debug)]
307pub(super) struct InitOptions {
308 pub(super) state_dir: PathBuf,
309 pub(super) listen: SocketAddr,
310 pub(super) tls: Option<TlsConfig>,
311 pub(super) cloudflare: Option<CloudflareInit>,
312}
313
314pub enum CloudflareInit {
316 Quick,
318 Named {
320 hostname: String,
322 token: String,
324 },
325}
326
327impl std::fmt::Debug for CloudflareInit {
328 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329 match self {
330 Self::Quick => formatter.write_str("CloudflareInit::Quick"),
331 Self::Named { hostname, .. } => formatter
332 .debug_struct("CloudflareInit::Named")
333 .field("hostname", hostname)
334 .field("token", &"[redacted]")
335 .finish(),
336 }
337 }
338}
339
340#[derive(Debug)]
341pub(super) struct ConnectOptions {
342 pub(super) state_dir: PathBuf,
343 pub(super) endpoint: Option<Endpoint>,
344}
345
346#[derive(Debug)]
347pub(super) struct RegisterProviderOptions {
348 pub(super) service_tier: Option<String>,
349 pub(super) state_dir: PathBuf,
350 pub(super) provider: String,
351 pub(super) instance: Option<String>,
352 pub(super) label: Option<String>,
353 pub(super) model: String,
354 pub(super) reasoning_efforts: Vec<String>,
355 pub(super) web_search: HostedWebSearch,
356 pub(super) base_url: Option<String>,
357 pub(super) credentialless: bool,
358 pub(super) credential_stdin: bool,
359 pub(super) credential_expires_at: Option<u64>,
360}
361
362impl GatewayCli {
363 pub fn frontend_command(&self) -> Result<Option<FrontendCommand>> {
368 let command = match &self.command {
369 None => FrontendCommand::Dashboard(self.resolved_state_dir()?),
370 Some(GatewaySubcommand::Provider) => {
371 FrontendCommand::Provider(self.resolved_state_dir()?)
372 }
373 Some(GatewaySubcommand::Init(arguments)) if arguments.is_interactive() => {
374 FrontendCommand::Init(self.resolved_state_dir()?)
375 }
376 _ => return Ok(None),
377 };
378 Ok(Some(command))
379 }
380
381 fn resolved_state_dir(&self) -> Result<PathBuf> {
382 self.state_dir.clone().map_or_else(state_dir, Ok)
383 }
384
385 pub(super) fn into_command(self) -> Result<Command> {
386 self.into_command_with_state(state_dir)
387 }
388
389 fn into_command_with_state(self, resolve: impl FnOnce() -> Result<PathBuf>) -> Result<Command> {
390 let command = match self.command {
391 Some(GatewaySubcommand::PrintDefaultConfig) => return Ok(Command::PrintDefaultConfig),
392 Some(GatewaySubcommand::ExportComputerResources { directory }) => {
393 return Ok(Command::ExportComputerResources { directory });
394 }
395 command => command,
396 };
397 let state_dir = self.state_dir.map_or_else(resolve, Ok)?;
398 match command {
399 Some(GatewaySubcommand::CheckConfig) => Ok(Command::CheckConfig { state_dir }),
400 Some(GatewaySubcommand::Telemetry { command }) => {
401 Ok(Command::Telemetry { state_dir, command })
402 }
403 Some(GatewaySubcommand::SetRuntime {
404 idle_exit_seconds,
405 storage_limit_bytes,
406 ingress,
407 clear_ingress,
408 clear_storage_limit,
409 }) => Ok(Command::SetRuntime {
410 state_dir,
411 idle_exit_seconds,
412 storage_limit_bytes,
413 ingress,
414 clear_ingress,
415 clear_storage_limit,
416 }),
417 Some(GatewaySubcommand::Init(arguments)) => {
418 parse_init(state_dir, arguments).map(Command::Init)
419 }
420 Some(GatewaySubcommand::Bootstrap) => Ok(Command::Bootstrap { state_dir }),
421 Some(GatewaySubcommand::ResetBotDefaults) => {
422 Ok(Command::ResetBotDefaults { state_dir })
423 }
424 Some(GatewaySubcommand::SetDesktop { enabled }) => {
425 Ok(Command::SetDesktop { state_dir, enabled })
426 }
427 Some(GatewaySubcommand::PairingCode { json: _ }) => {
428 Ok(Command::PairingCode { state_dir })
429 }
430 Some(GatewaySubcommand::ClearProviderCredential { instance }) => {
431 Ok(Command::ClearProviderCredential {
432 state_dir,
433 instance,
434 })
435 }
436 Some(GatewaySubcommand::RegisterProvider(arguments)) => {
437 Ok(Command::RegisterProvider(RegisterProviderOptions {
438 service_tier: arguments.service_tier,
439 state_dir,
440 provider: arguments.provider,
441 instance: arguments.instance,
442 label: arguments.label,
443 model: arguments.model,
444 reasoning_efforts: arguments.reasoning_efforts,
445 web_search: arguments.web_search,
446 base_url: arguments.base_url,
447 credentialless: arguments.credentialless,
448 credential_stdin: arguments.credential_stdin,
449 credential_expires_at: arguments.credential_expires_at,
450 }))
451 }
452 Some(GatewaySubcommand::Connect(arguments)) => Ok(Command::Connect(ConnectOptions {
453 state_dir,
454 endpoint: arguments.endpoint,
455 })),
456 Some(GatewaySubcommand::Serve(arguments)) => Ok(Command::Serve {
457 state_dir,
458 background: arguments.background,
459 }),
460 Some(GatewaySubcommand::ServeChild) => Ok(Command::ServeChild { state_dir }),
461 Some(GatewaySubcommand::Exit) => Ok(Command::Exit { state_dir }),
462 None
463 | Some(
464 GatewaySubcommand::Provider
465 | GatewaySubcommand::PrintDefaultConfig
466 | GatewaySubcommand::ExportComputerResources { .. },
467 ) => Err(Error::Config(
468 "an executable gateway command is required".into(),
469 )),
470 }
471 }
472}
473
474pub(super) fn parse_cli(arguments: Vec<OsString>) -> std::result::Result<GatewayCli, clap::Error> {
475 GatewayCli::try_parse_from(std::iter::once(OsString::from("mobius-gateway")).chain(arguments))
476}
477
478#[cfg(test)]
479pub(super) fn parse(arguments: Vec<OsString>) -> Result<Command> {
480 parse_cli(arguments)
481 .map_err(|error| Error::Config(error.to_string()))?
482 .into_command()
483}
484
485fn parse_init(state_dir: PathBuf, arguments: InitArgs) -> Result<InitOptions> {
486 let tls = match (arguments.certificate, arguments.private_key) {
487 (Some(certificate), Some(private_key)) => Some(TlsConfig {
488 certificate: std::fs::canonicalize(certificate)?,
489 private_key: std::fs::canonicalize(private_key)?,
490 }),
491 (None, None) => None,
492 _ => {
493 return Err(Error::Config(
494 "--tls-cert and --tls-key must be supplied together".into(),
495 ));
496 }
497 };
498 let cloudflare = match (
499 arguments.cloudflare_hostname,
500 arguments.cloudflare_token_file,
501 ) {
502 (Some(hostname), Some(path)) => Some(CloudflareInit::Named {
503 hostname,
504 token: load_secret_file(&path)?,
505 }),
506 (None, None) => None,
507 _ => {
508 return Err(Error::Config(
509 "--cloudflare-hostname and --cloudflare-token-file must be supplied together"
510 .into(),
511 ));
512 }
513 };
514 Ok(InitOptions {
515 state_dir,
516 listen: arguments.listen.unwrap_or(DEFAULT_LISTEN),
517 tls,
518 cloudflare,
519 })
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 fn unavailable_state() -> Result<PathBuf> {
527 Err(Error::Config("no home directory configured".into()))
528 }
529
530 #[test]
531 fn state_independent_commands_do_not_resolve_gateway_state() {
532 let print = parse_cli(vec!["print-default-config".into()])
533 .expect("print command")
534 .into_command_with_state(unavailable_state)
535 .expect("defaults do not require state");
536 assert!(matches!(print, Command::PrintDefaultConfig));
537
538 let export = parse_cli(vec![
539 "export-computer-resources".into(),
540 "--directory".into(),
541 "computer-resources".into(),
542 ])
543 .expect("export command")
544 .into_command_with_state(unavailable_state)
545 .expect("resource export does not require state");
546 assert!(matches!(export,
547 Command::ExportComputerResources { directory }
548 if directory == Path::new("computer-resources")
549 ));
550
551 let check = parse_cli(vec!["check-config".into()])
552 .expect("check command")
553 .into_command_with_state(unavailable_state)
554 .expect_err("checking persisted configuration requires state");
555 assert!(check.to_string().contains("no home directory configured"));
556 }
557}