liminal-cli 0.3.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, Credential, 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; a seat file is accepted too.
    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; absent, the whole store is counted (DUR-028 R6).
        #[arg(long)]
        conversation: Option<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,
    /// Host a catalogued ACP harness on a Lys-launched seat.
    Agent {
        #[command(subcommand)]
        command: liminal_cli::agent::AgentCommand,
    },
    /// Write a seat file naming the central exchange and Lys.
    Seat {
        #[command(subcommand)]
        command: SeatCommand,
    },
}

#[derive(Debug, clap::Subcommand)]
enum SeatCommand {
    /// Write a new seat file, mode 0600; an existing one is never replaced.
    Init {
        /// The agent id Lys knows this seat by.
        #[arg(long)]
        agent: String,
        /// The agent's key file.
        #[arg(long)]
        key_file: PathBuf,
        /// Where to write the seat file.
        #[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>]"
            }
        }
    }
}

/// `liminal check`: a server configuration is told from a seat by its
/// top-level fields before either loader runs, and each is read by its own
/// loader (ACCESS-010 R4). A seat's credential is named by kind only; no
/// token byte and no key path's contents are printed.
fn check_configuration(path: &Path) -> anyhow::Result<()> {
    // A seat is told by its top-level fields (ACCESS-010 R4); a file that is
    // not a readable seat, a missing or malformed one included, goes to the
    // server loader exactly as before, with its words.
    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(())
}

/// 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) => 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);
        }
    };
    // AGT-006 R3: a dead local address is named before the handshake.
    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)
        }
    }
}