liminal-cli 0.2.1

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
//! The liminal command serves the network, maintains its stores, or serves
//! its discovery MCP on stdin and stdout.

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, Live, Server};

mod identity;

/// Server lifecycle events stay visible while dependency logs start at warn.
const DEFAULT_FILTER: &str = "warn,liminal_server=info,liminal=info";

#[derive(Debug, Parser)]
#[command(name = "liminal", arg_required_else_help = true)]
struct Cli {
    /// The server configuration, or the seat configuration for MCP.
    #[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 {
    /// Validate the server configuration without serving, opening the store or writing.
    Check,
    /// Print the agent public key and a signed certificate request; create its key if absent.
    Identity,
    /// Rewrite participant conversation logs offline. Run with the server stopped.
    MigrateParticipantLogs,
    /// Count one conversation's checkpoint without writing. Run offline or on a copy.
    CheckpointCensus {
        /// The conversation to count.
        #[arg(long)]
        conversation: u64,
        /// A store directory to read instead of the configuration's persistence path.
        #[arg(long)]
        store: Option<PathBuf>,
    },
    /// Serve the discovery MCP over stdin and stdout on the seat's pass.
    Mcp,
}

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>",
        }
    }
}

/// Stderr leaves stdout available for maintenance output and the MCP wire.
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) => {
            liminal_server::config::file::load_config(config)?;
            println!("{}: configuration valid", config.display());
        }
        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"),
    }
    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 frame_bytes = config.limits.frame_bytes;
    let live = match Live::new(&config) {
        Ok(live) => live,
        Err(error) => {
            eprintln!("liminal mcp: {error}");
            return ExitCode::from(1);
        }
    };
    let mut server = Server::new(live, frame_bytes);
    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();
    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 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)
        }
    }
}