Skip to main content

mobius_gateway/command/
args.rs

1use clap::{ArgAction, Args, Parser, Subcommand};
2use mobius::backend::model::provider::HostedWebSearch;
3
4use super::*;
5
6/// Parsed `mobius-gateway` command line.
7#[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    /// Directory containing gateway configuration and runtime state.
16    #[arg(long, global = true, value_name = "PATH")]
17    state_dir: Option<PathBuf>,
18
19    #[command(subcommand)]
20    command: Option<GatewaySubcommand>,
21}
22
23/// Frontend selected by a parsed gateway command line.
24#[derive(Debug)]
25pub enum FrontendCommand {
26    /// Interactive Cloudflare initialization.
27    Init(PathBuf),
28    /// Gateway administration dashboard.
29    Dashboard(PathBuf),
30    /// Interactive provider setup.
31    Provider(PathBuf),
32}
33
34#[derive(Debug, Subcommand)]
35enum GatewaySubcommand {
36    /// Print complete default gateway TOML without creating state.
37    PrintDefaultConfig,
38    /// Validate the current gateway TOML without starting services.
39    CheckConfig,
40    /// Export the matching computer worker and documentation for offline installs.
41    ExportComputerResources {
42        #[arg(long, value_name = "PATH")]
43        directory: PathBuf,
44    },
45    /// Open the provider setup interface.
46    Provider,
47    /// Initialize gateway state.
48    Init(InitArgs),
49    /// Initialize a direct loopback gateway for machine use.
50    Bootstrap,
51    /// Restore the default Bot configuration.
52    ResetBotDefaults,
53    /// Enable or disable the gateway-owned desktop while the gateway is stopped.
54    SetDesktop {
55        /// Use true to enable the gateway-owned desktop or false to disable it.
56        #[arg(long, action = ArgAction::Set, hide_possible_values = true)]
57        enabled: bool,
58    },
59    /// Issue a one-time pairing code as JSON.
60    PairingCode {
61        /// Emit machine-readable JSON.
62        #[arg(long, required = true)]
63        json: bool,
64    },
65    /// Register a model provider non-interactively.
66    RegisterProvider(RegisterProviderArgs),
67    /// Revoke a stored credential and cancel operations that use it.
68    ClearProviderCredential {
69        #[arg(long)]
70        instance: String,
71    },
72    /// Connect this installation to a running gateway.
73    Connect(ConnectArgs),
74    /// Run the gateway server.
75    Serve(ServeArgs),
76    #[command(name = "__serve", hide = true)]
77    ServeChild,
78    /// Manage outbound telemetry collectors.
79    Telemetry {
80        #[command(subcommand)]
81        command: TelemetryCommand,
82    },
83    /// Set lifecycle policy while the gateway is stopped.
84    SetRuntime {
85        #[arg(long)]
86        idle_exit_seconds: Option<u64>,
87        /// Informational remote allowance, without local upload enforcement.
88        #[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    /// Stop a background gateway.
98    Exit,
99}
100
101#[derive(Debug, Subcommand)]
102pub(super) enum TelemetryCommand {
103    /// Print delivery status and safe endpoint configuration as JSON.
104    List,
105    /// Upsert an endpoint by ID.
106    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        /// Require this collector's decision before accepting a user upload.
114        #[arg(long)]
115        upload_admission: bool,
116        /// Snapshot sections: activity, usage, runs, storage, resources.
117        #[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 an endpoint by ID.
129    Remove {
130        #[arg(long)]
131        id: String,
132    },
133}
134
135#[derive(Debug, Args)]
136struct InitArgs {
137    /// Address on which the gateway listens.
138    #[arg(long, value_name = "ADDR")]
139    listen: Option<SocketAddr>,
140
141    /// PEM certificate for a direct TLS listener.
142    #[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    /// PEM private key for a direct TLS listener.
151    #[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    /// Public hostname served by a named Cloudflare tunnel.
160    #[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    /// Owner-only file containing the named Cloudflare tunnel token.
169    #[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    /// Native Responses processing tier; omitted to use the endpoint default.
191    #[arg(long, value_name = "TIER")]
192    service_tier: Option<String>,
193    /// Provider identifier.
194    #[arg(long, value_name = "ID")]
195    provider: String,
196
197    /// Stable identifier for this configured provider instance.
198    #[arg(long, value_name = "ID")]
199    instance: Option<String>,
200
201    /// User-facing provider label.
202    #[arg(long, value_name = "TEXT")]
203    label: Option<String>,
204
205    /// Provider model identifier.
206    #[arg(long, value_name = "ID")]
207    model: String,
208
209    /// Comma-separated reasoning effort identifiers.
210    #[arg(
211        long,
212        value_name = "CSV",
213        value_delimiter = ',',
214        action = ArgAction::Set
215    )]
216    reasoning_efforts: Vec<String>,
217
218    /// Hosted web-search mode: off, cached, or live.
219    #[arg(long, value_name = "MODE", default_value = "off")]
220    web_search: HostedWebSearch,
221
222    /// Provider API base URL override.
223    #[arg(long, value_name = "URL")]
224    base_url: Option<String>,
225
226    /// Configure an endpoint that does not require a credential.
227    #[arg(long, conflicts_with = "credential_stdin")]
228    credentialless: bool,
229
230    /// Read the provider credential from standard input.
231    #[arg(long)]
232    credential_stdin: bool,
233
234    /// Expire the piped credential at this Unix timestamp (seconds).
235    #[arg(long, value_name = "TIMESTAMP", requires = "credential_stdin")]
236    credential_expires_at: Option<u64>,
237}
238
239#[derive(Debug, Args)]
240struct ConnectArgs {
241    /// Public or local gateway endpoint.
242    #[arg(long, value_name = "ENDPOINT")]
243    endpoint: Option<Endpoint>,
244}
245
246#[derive(Debug, Args)]
247struct ServeArgs {
248    /// Start the gateway as a background process.
249    #[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
314/// Cloudflare exposure selected during gateway initialization.
315pub enum CloudflareInit {
316    /// Account-free temporary tunnel.
317    Quick,
318    /// Named tunnel with a credential that is redacted in debug output.
319    Named {
320        /// Public tunnel hostname.
321        hostname: String,
322        /// Cloudflare tunnel credential.
323        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    /// Returns the interactive frontend selected by this command line, if any.
364    /// # Errors
365    ///
366    /// Returns an error if validation or an operation required by this function fails.
367    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}