use std::io::{self, IsTerminal};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{CommandFactory, Parser, builder::TypedValueParser, error::ErrorKind};
use liminal_cli::{Config, Credential, Live, Server};
mod identity;
const DEFAULT_FILTER: &str = "warn,liminal_server=info,liminal=info";
#[derive(Debug, Parser)]
#[command(name = "liminal", arg_required_else_help = true)]
struct Cli {
#[arg(long, global = true, value_parser = clap::builder::OsStringValueParser::new().map(PathBuf::from))]
config: Option<PathBuf>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, clap::Subcommand)]
enum Command {
Check,
Identity,
MigrateParticipantLogs,
CheckpointCensus {
#[arg(long)]
conversation: Option<u64>,
#[arg(long)]
store: Option<PathBuf>,
},
Mcp,
Agent {
#[command(subcommand)]
command: liminal_cli::agent::AgentCommand,
},
Seat {
#[command(subcommand)]
command: SeatCommand,
},
}
#[derive(Debug, clap::Subcommand)]
enum SeatCommand {
Init {
#[arg(long)]
agent: String,
#[arg(long)]
key_file: PathBuf,
#[arg(long, default_value = "seat.toml")]
out: PathBuf,
},
}
impl Command {
const fn usage(&self) -> &'static str {
match self {
Self::Check => "liminal check --config <CONFIG>",
Self::Identity => "liminal identity --config <CONFIG>",
Self::MigrateParticipantLogs => "liminal migrate-participant-logs --config <CONFIG>",
Self::CheckpointCensus { .. } => {
"liminal checkpoint-census --config <CONFIG> [--conversation <CONVERSATION>] [--store <STORE>]"
}
Self::Mcp => "liminal mcp --config <CONFIG>",
Self::Agent { .. } => "liminal agent run --config <CONFIG>",
Self::Seat { .. } => {
"liminal seat init --agent <AGENT> --key-file <KEY_FILE> [--out <OUT>]"
}
}
}
}
fn check_configuration(path: &Path) -> anyhow::Result<()> {
let table = std::fs::read_to_string(path)
.ok()
.and_then(|text| text.parse::<toml::Table>().ok());
let (server, seat) = table.as_ref().map_or((true, false), |table| {
(
table.contains_key("listen_address"),
table.contains_key("channel"),
)
});
if server {
liminal_server::config::file::load_config(path)?;
println!("{}: configuration valid", path.display());
} else if seat {
let config = Config::load(path)?;
let credential = match &config.credential {
Credential::Open => "open".to_owned(),
Credential::Token(_) => "token file".to_owned(),
Credential::Lys(lys) => format!("lys issuer {}", lys.issuer),
};
println!("{}: seat configuration ok: {credential}", path.display());
} else {
anyhow::bail!(
"{}: neither a server configuration (no listen_address) nor a seat (no channel)",
path.display()
);
}
Ok(())
}
fn init_tracing() -> anyhow::Result<()> {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(DEFAULT_FILTER));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_ansi(io::stderr().is_terminal())
.with_writer(io::stderr)
.try_init()
.map_err(|error| anyhow::anyhow!("failed to initialize tracing: {error}"))
}
fn run_server(config: &Path, command: Option<Command>) -> anyhow::Result<()> {
if !matches!(command, Some(Command::Check)) {
init_tracing()?;
}
match command {
None => liminal_server::run(config)?,
Some(Command::Check) => check_configuration(config)?,
Some(Command::Identity) => identity::run(config)?,
Some(Command::MigrateParticipantLogs) => {
let migration = liminal_server::run_participant_log_migration(config)?;
for conversation in &migration.conversations {
println!(
"{} {:?}",
conversation.conversation_id, conversation.outcome
);
}
}
Some(Command::CheckpointCensus {
conversation,
store,
}) => {
for line in
liminal_server::run_checkpoint_census(config, conversation, store.as_deref())?
{
println!("{line}");
}
}
Some(Command::Mcp) => anyhow::bail!("mcp must use the seat configuration"),
Some(Command::Agent { .. }) => anyhow::bail!("agent must use the seat configuration"),
Some(Command::Seat { .. }) => anyhow::bail!("seat init takes no configuration"),
}
Ok(())
}
fn run_mcp(path: &Path) -> ExitCode {
let config = match Config::load(path) {
Ok(config) => config,
Err(error) => {
eprintln!("liminal mcp: {error}");
return ExitCode::from(1);
}
};
let mut local = vec![config.server.as_str()];
if let liminal_cli::Credential::Lys(lys) = &config.credential {
local.push(lys.issuer.as_str());
}
for address in local {
if let Err(refusal) =
liminal_cli::seat::refuse_unanswered_loopback(address, config.limits.connect_timeout())
{
eprintln!("liminal mcp: {refusal}");
return ExitCode::from(1);
}
}
let live = match Live::new(&config) {
Ok(live) => live,
Err(error) => {
eprintln!("liminal mcp: {error}");
return ExitCode::from(1);
}
};
let mut server = match Server::configured(live, &config) {
Ok(server) => server,
Err(error) => {
eprintln!("liminal mcp: {error}");
return ExitCode::from(1);
}
};
let stdin = io::stdin();
let stdout = io::stdout();
let mut input = stdin.lock();
let mut output = stdout;
match server.serve(&mut input, &mut output) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("liminal mcp: {error}");
ExitCode::from(3)
}
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
if let Some(Command::Seat {
command:
SeatCommand::Init {
agent,
key_file,
out,
},
}) = &cli.command
{
return match liminal_cli::seat::init(agent, key_file, out) {
Ok(()) => {
println!("{}: seat written", out.display());
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("liminal seat init: {error}");
ExitCode::from(1)
}
};
}
let Some(config) = cli.config else {
let usage = cli
.command
.as_ref()
.map_or("liminal --config <CONFIG>", Command::usage);
Cli::command()
.override_usage(usage)
.error(
ErrorKind::MissingRequiredArgument,
"--config <CONFIG> is required",
)
.exit();
};
if let Some(Command::Agent { command }) = cli.command {
return liminal_cli::agent::run(&config, command);
}
if matches!(cli.command, Some(Command::Mcp)) {
return run_mcp(&config);
}
let checking = matches!(cli.command, Some(Command::Check));
match run_server(&config, cli.command) {
Ok(()) => ExitCode::SUCCESS,
Err(error) if checking => {
eprintln!("{error}");
ExitCode::from(2)
}
Err(error) => {
eprintln!("Error: {error:#}");
ExitCode::from(1)
}
}
}