use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use faculties::hear;
use faculties::mcp::catalog::{Catalog, Config as CatalogConfig};
use faculties::mcp::http;
use faculties::mcp::{Faculty, Server};
use std::net::SocketAddr;
use std::path::PathBuf;
#[derive(Parser)]
#[command(version = faculties::GIT_VERSION, about = "Native Faculties frontends")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Mcp {
#[arg(long, env = "PILE")]
pile: PathBuf,
#[arg(long, env = "TRIBLESPACE_KEY")]
key: Option<PathBuf>,
#[arg(long, env = "DISCORD_TOKEN", hide_env_values = true)]
discord_token: Option<String>,
#[arg(long, env = "LINKEDIN_TOKEN", hide_env_values = true)]
linkedin_token: Option<String>,
#[arg(long, env = "DUPLEX_SESSION")]
duplex_session: Option<PathBuf>,
#[arg(long, env = "HEAR_MODEL_PILE", requires_all = ["hear_config_json", "hear_tokenizer_json"])]
hear_model_pile: Option<PathBuf>,
#[arg(long, env = "HEAR_MODEL", default_value = hear::DEFAULT_MODEL)]
hear_model: String,
#[arg(long, env = "HEAR_CONFIG_JSON", requires = "hear_model_pile")]
hear_config_json: Option<PathBuf>,
#[arg(long, env = "HEAR_TOKENIZER_JSON", requires = "hear_model_pile")]
hear_tokenizer_json: Option<PathBuf>,
#[arg(long, value_name = "ADDR", requires = "http_token_file")]
http_listen: Option<SocketAddr>,
#[arg(
long,
env = "FACULTIES_MCP_TOKEN_FILE",
hide_env_values = true,
value_name = "PATH",
requires = "http_listen"
)]
http_token_file: Option<PathBuf>,
#[arg(long, value_name = "URL", requires = "http_listen")]
http_origin: Vec<String>,
},
}
fn main() -> Result<()> {
let Cli { command } = Cli::parse();
match command {
Command::Mcp {
pile,
key,
discord_token,
linkedin_token,
duplex_session,
hear_model_pile,
hear_model,
hear_config_json,
hear_tokenizer_json,
http_listen,
http_token_file,
http_origin,
} => {
let hear = match (hear_model_pile, hear_config_json, hear_tokenizer_json) {
(None, None, None) => None,
(Some(pile), Some(config_json), Some(tokenizer_json)) => Some(hear::ModelConfig {
pile,
model: hear_model,
config_json,
tokenizer_json,
}),
_ => anyhow::bail!(
"Hear requires its model pile, configuration JSON, and tokenizer JSON together"
),
};
let catalog = Catalog::new(CatalogConfig {
pile,
key,
discord_token,
linkedin_token,
duplex_session,
hear,
});
let registrations = catalog.registrations();
let result = match http_listen {
Some(bind) => {
let token_file = http_token_file
.as_deref()
.context("HTTP requires --http-token-file")?;
let token = http::BearerToken::from_file(token_file)?;
let mut config = http::Config::new(bind, token);
config.allowed_origins = http_origin;
detach_handler_stdio()?;
http::serve(®istrations, config)
}
None => serve_stdio(®istrations),
};
catalog.finish(result)
}
}
}
#[cfg(unix)]
fn serve_stdio(registrations: &[&dyn Faculty]) -> Result<()> {
use std::fs::File;
use std::io::BufReader;
use std::os::fd::AsFd;
let input = File::from(std::io::stdin().as_fd().try_clone_to_owned()?);
let output = File::from(std::io::stdout().as_fd().try_clone_to_owned()?);
detach_handler_stdio()?;
Server::new(registrations)?.serve(BufReader::new(input), output)
}
#[cfg(unix)]
fn detach_handler_stdio() -> Result<()> {
use std::fs::File;
use std::io::Write;
use std::os::fd::AsRawFd;
let empty = File::open("/dev/null").context("open empty handler stdin")?;
std::io::stdout().flush()?;
for (from, to) in [
(empty.as_raw_fd(), libc::STDIN_FILENO),
(libc::STDERR_FILENO, libc::STDOUT_FILENO),
] {
if unsafe { libc::dup2(from, to) } == -1 {
return Err(std::io::Error::last_os_error()).context("detach handler stdio");
}
}
Ok(())
}
#[cfg(not(unix))]
fn detach_handler_stdio() -> Result<()> {
Ok(())
}
#[cfg(not(unix))]
fn serve_stdio(registrations: &[&dyn Faculty]) -> Result<()> {
Server::new(registrations)?.serve(std::io::stdin().lock(), std::io::stdout().lock())
}