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    /// Open the provider setup interface.
37    Provider,
38    /// Initialize gateway state.
39    Init(InitArgs),
40    /// Initialize a direct loopback gateway for machine use.
41    Bootstrap,
42    /// Restore the default Bot configuration.
43    ResetBotDefaults,
44    /// Issue a one-time pairing code as JSON.
45    PairingCode {
46        /// Emit machine-readable JSON.
47        #[arg(long, required = true)]
48        json: bool,
49    },
50    /// Register a model provider non-interactively.
51    RegisterProvider(RegisterProviderArgs),
52    /// Revoke a stored credential and cancel operations that use it.
53    ClearProviderCredential {
54        #[arg(long)]
55        instance: String,
56    },
57    /// Connect this installation to a running gateway.
58    Connect(ConnectArgs),
59    /// Run the gateway server.
60    Serve(ServeArgs),
61    #[command(name = "__serve", hide = true)]
62    ServeChild,
63    /// Open the installed macOS gateway menu bar app.
64    #[cfg(target_os = "macos")]
65    MenuBar,
66    #[cfg(target_os = "macos")]
67    #[command(name = "__menu-bar-connect", hide = true)]
68    MenuBarConnect,
69    /// Stop a background gateway.
70    Exit,
71}
72
73#[derive(Debug, Args)]
74struct InitArgs {
75    /// Address on which the gateway listens.
76    #[arg(long, value_name = "ADDR")]
77    listen: Option<SocketAddr>,
78
79    /// PEM certificate for a direct TLS listener.
80    #[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    /// PEM private key for a direct TLS listener.
89    #[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    /// Public hostname served by a named Cloudflare tunnel.
98    #[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    /// Owner-only file containing the named Cloudflare tunnel token.
107    #[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    /// Provider identifier.
129    #[arg(long, value_name = "ID")]
130    provider: String,
131
132    /// Stable identifier for this configured provider instance.
133    #[arg(long, value_name = "ID")]
134    instance: Option<String>,
135
136    /// User-facing provider label.
137    #[arg(long, value_name = "TEXT")]
138    label: Option<String>,
139
140    /// Provider model identifier.
141    #[arg(long, value_name = "ID")]
142    model: String,
143
144    /// Comma-separated reasoning effort identifiers.
145    #[arg(
146        long,
147        value_name = "CSV",
148        value_delimiter = ',',
149        action = ArgAction::Set
150    )]
151    reasoning_efforts: Vec<String>,
152
153    /// Hosted web-search mode: off, cached, or live.
154    #[arg(long, value_name = "MODE", default_value = "off")]
155    web_search: HostedWebSearch,
156
157    /// Provider API base URL override.
158    #[arg(long, value_name = "URL")]
159    base_url: Option<String>,
160
161    /// Configure an endpoint that does not require a credential.
162    #[arg(long, conflicts_with = "credential_stdin")]
163    credentialless: bool,
164
165    /// Read the provider credential from standard input.
166    #[arg(long)]
167    credential_stdin: bool,
168
169    /// Expire the piped credential at this Unix timestamp (seconds).
170    #[arg(long, value_name = "TIMESTAMP", requires = "credential_stdin")]
171    credential_expires_at: Option<u64>,
172}
173
174#[derive(Debug, Args)]
175struct ConnectArgs {
176    /// Public or local gateway endpoint.
177    #[arg(long, value_name = "ENDPOINT")]
178    endpoint: Option<Endpoint>,
179}
180
181#[derive(Debug, Args)]
182struct ServeArgs {
183    /// Start the gateway as a background process.
184    #[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    /// Returns the interactive frontend selected by this command line, if any.
275    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}