use clap::{ArgAction, Args, Parser, Subcommand};
use mobius::backend::model::provider::HostedWebSearch;
use super::*;
#[derive(Debug, Parser)]
#[command(
name = "mobius-gateway",
version,
propagate_version = true,
about = "Run and configure a möbius gateway"
)]
pub struct GatewayCli {
#[arg(long, global = true, value_name = "PATH")]
state_dir: Option<PathBuf>,
#[command(subcommand)]
command: Option<GatewaySubcommand>,
}
#[derive(Debug)]
pub enum FrontendCommand {
Init(PathBuf),
Dashboard(PathBuf),
Provider(PathBuf),
}
#[derive(Debug, Subcommand)]
enum GatewaySubcommand {
Provider,
Init(InitArgs),
Bootstrap,
ResetBotDefaults,
PairingCode {
#[arg(long, required = true)]
json: bool,
},
RegisterProvider(RegisterProviderArgs),
Connect(ConnectArgs),
Serve(ServeArgs),
#[command(name = "__serve", hide = true)]
ServeChild,
Exit,
}
#[derive(Debug, Args)]
struct InitArgs {
#[arg(long, value_name = "ADDR")]
listen: Option<SocketAddr>,
#[arg(
long = "tls-cert",
value_name = "PATH",
requires = "private_key",
conflicts_with_all = ["cloudflare_hostname", "cloudflare_token_file"]
)]
certificate: Option<PathBuf>,
#[arg(
long = "tls-key",
value_name = "PATH",
requires = "certificate",
conflicts_with_all = ["cloudflare_hostname", "cloudflare_token_file"]
)]
private_key: Option<PathBuf>,
#[arg(
long,
value_name = "HOST",
requires = "cloudflare_token_file",
conflicts_with_all = ["certificate", "private_key"]
)]
cloudflare_hostname: Option<String>,
#[arg(
long,
value_name = "PATH",
requires = "cloudflare_hostname",
conflicts_with_all = ["certificate", "private_key"]
)]
cloudflare_token_file: Option<PathBuf>,
}
impl InitArgs {
fn is_interactive(&self) -> bool {
self.listen.is_none()
&& self.certificate.is_none()
&& self.private_key.is_none()
&& self.cloudflare_hostname.is_none()
&& self.cloudflare_token_file.is_none()
}
}
#[derive(Debug, Args)]
struct RegisterProviderArgs {
#[arg(long, value_name = "ID")]
provider: String,
#[arg(long, value_name = "ID")]
instance: Option<String>,
#[arg(long, value_name = "TEXT")]
label: Option<String>,
#[arg(long, value_name = "ID")]
model: String,
#[arg(
long,
value_name = "CSV",
value_delimiter = ',',
action = ArgAction::Set
)]
reasoning_efforts: Vec<String>,
#[arg(long, value_name = "MODE", default_value = "off")]
web_search: HostedWebSearch,
#[arg(long, value_name = "URL")]
base_url: Option<String>,
#[arg(long, conflicts_with = "credential_stdin")]
credentialless: bool,
#[arg(long)]
credential_stdin: bool,
}
#[derive(Debug, Args)]
struct ConnectArgs {
#[arg(long, value_name = "ENDPOINT")]
endpoint: Option<Endpoint>,
}
#[derive(Debug, Args)]
struct ServeArgs {
#[arg(long)]
background: bool,
}
#[derive(Debug)]
pub(super) enum Command {
Init(InitOptions),
Bootstrap {
state_dir: PathBuf,
},
ResetBotDefaults {
state_dir: PathBuf,
},
PairingCode {
state_dir: PathBuf,
},
RegisterProvider(RegisterProviderOptions),
Connect(ConnectOptions),
Serve {
state_dir: PathBuf,
background: bool,
},
ServeChild {
state_dir: PathBuf,
},
Exit {
state_dir: PathBuf,
},
}
#[derive(Debug)]
pub(super) struct InitOptions {
pub(super) state_dir: PathBuf,
pub(super) listen: SocketAddr,
pub(super) tls: Option<TlsConfig>,
pub(super) cloudflare: Option<CloudflareInit>,
}
pub(super) enum CloudflareInit {
Quick,
Named { hostname: String, token: String },
}
impl std::fmt::Debug for CloudflareInit {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Quick => formatter.write_str("CloudflareInit::Quick"),
Self::Named { hostname, .. } => formatter
.debug_struct("CloudflareInit::Named")
.field("hostname", hostname)
.field("token", &"[redacted]")
.finish(),
}
}
}
#[derive(Debug)]
pub(super) struct ConnectOptions {
pub(super) state_dir: PathBuf,
pub(super) endpoint: Option<Endpoint>,
}
#[derive(Debug)]
pub(super) struct RegisterProviderOptions {
pub(super) state_dir: PathBuf,
pub(super) provider: String,
pub(super) instance: Option<String>,
pub(super) label: Option<String>,
pub(super) model: String,
pub(super) reasoning_efforts: Vec<String>,
pub(super) web_search: HostedWebSearch,
pub(super) base_url: Option<String>,
pub(super) credentialless: bool,
pub(super) credential_stdin: bool,
}
impl GatewayCli {
pub fn frontend_command(&self) -> Result<Option<FrontendCommand>> {
let command = match &self.command {
None => FrontendCommand::Dashboard(self.resolved_state_dir()?),
Some(GatewaySubcommand::Provider) => {
FrontendCommand::Provider(self.resolved_state_dir()?)
}
Some(GatewaySubcommand::Init(arguments)) if arguments.is_interactive() => {
FrontendCommand::Init(self.resolved_state_dir()?)
}
_ => return Ok(None),
};
Ok(Some(command))
}
fn resolved_state_dir(&self) -> Result<PathBuf> {
self.state_dir.clone().map_or_else(state_dir, Ok)
}
pub(super) fn into_command(self) -> Result<Command> {
let state_dir = self.state_dir.map_or_else(state_dir, Ok)?;
match self.command {
Some(GatewaySubcommand::Init(arguments)) => {
parse_init(state_dir, arguments).map(Command::Init)
}
Some(GatewaySubcommand::Bootstrap) => Ok(Command::Bootstrap { state_dir }),
Some(GatewaySubcommand::ResetBotDefaults) => {
Ok(Command::ResetBotDefaults { state_dir })
}
Some(GatewaySubcommand::PairingCode { json: _ }) => {
Ok(Command::PairingCode { state_dir })
}
Some(GatewaySubcommand::RegisterProvider(arguments)) => {
Ok(Command::RegisterProvider(RegisterProviderOptions {
state_dir,
provider: arguments.provider,
instance: arguments.instance,
label: arguments.label,
model: arguments.model,
reasoning_efforts: arguments.reasoning_efforts,
web_search: arguments.web_search,
base_url: arguments.base_url,
credentialless: arguments.credentialless,
credential_stdin: arguments.credential_stdin,
}))
}
Some(GatewaySubcommand::Connect(arguments)) => Ok(Command::Connect(ConnectOptions {
state_dir,
endpoint: arguments.endpoint,
})),
Some(GatewaySubcommand::Serve(arguments)) => Ok(Command::Serve {
state_dir,
background: arguments.background,
}),
Some(GatewaySubcommand::ServeChild) => Ok(Command::ServeChild { state_dir }),
Some(GatewaySubcommand::Exit) => Ok(Command::Exit { state_dir }),
None | Some(GatewaySubcommand::Provider) => Err(Error::Config(
"an executable gateway command is required".into(),
)),
}
}
}
pub(super) fn parse_cli(arguments: Vec<OsString>) -> std::result::Result<GatewayCli, clap::Error> {
GatewayCli::try_parse_from(std::iter::once(OsString::from("mobius-gateway")).chain(arguments))
}
#[cfg(test)]
pub(super) fn parse(arguments: Vec<OsString>) -> Result<Command> {
parse_cli(arguments)
.map_err(|error| Error::Config(error.to_string()))?
.into_command()
}
fn parse_init(state_dir: PathBuf, arguments: InitArgs) -> Result<InitOptions> {
let tls = match (arguments.certificate, arguments.private_key) {
(Some(certificate), Some(private_key)) => Some(TlsConfig {
certificate: std::fs::canonicalize(certificate)?,
private_key: std::fs::canonicalize(private_key)?,
}),
(None, None) => None,
_ => {
return Err(Error::Config(
"--tls-cert and --tls-key must be supplied together".into(),
));
}
};
let cloudflare = match (
arguments.cloudflare_hostname,
arguments.cloudflare_token_file,
) {
(Some(hostname), Some(path)) => Some(CloudflareInit::Named {
hostname,
token: load_cloudflare_token(&path)?,
}),
(None, None) => None,
_ => {
return Err(Error::Config(
"--cloudflare-hostname and --cloudflare-token-file must be supplied together"
.into(),
));
}
};
Ok(InitOptions {
state_dir,
listen: arguments.listen.unwrap_or(DEFAULT_LISTEN),
tls,
cloudflare,
})
}