liminal-cli 0.3.30

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.
    /// The seat is the `--config` file, these flags, or both, a flag winning
    /// over the file's value; with no file, `seat init`'s channel and limits.
    Mcp {
        /// The exchange to dial (the file's `server`; the central exchange by default).
        #[arg(long)]
        server: Option<String>,
        /// The channel routed requests travel on (the file's `channel`).
        #[arg(long)]
        channel: Option<String>,
        /// The name this open seat declares (`[open] name`), recorded as `open:<name>`.
        #[arg(long = "as", value_name = "NAME")]
        name: Option<String>,
        /// The Lys agent id this seat is (`[lys] agent`), with --key-file.
        #[arg(long)]
        agent: Option<String>,
        /// The agent's key file (`[lys] key_file`), with --agent.
        #[arg(long, value_parser = clap::builder::OsStringValueParser::new().map(PathBuf::from))]
        key_file: Option<PathBuf>,
    },
    /// 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, required_unless_present = "open", conflicts_with = "open")]
        agent: Option<String>,
        /// The agent's key file.
        #[arg(long, required_unless_present = "open", conflicts_with = "open")]
        key_file: Option<PathBuf>,
        /// An open exchange's address (ACCESS-011 R1): the seat carries no
        /// credential and, off loopback, `[open] reach = "network"`.
        #[arg(long)]
        open: Option<String>,
        /// The name the seat declares to an open exchange (ACCESS-012 R2),
        /// recorded as `open:<name>`; anonymous without it.
        #[arg(long, requires = "open")]
        name: Option<String>,
        /// 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>] [--server <SERVER>] [--channel <CHANNEL>] \
                 [--as <NAME> | --agent <AGENT> --key-file <KEY_FILE>]"
            }
            Self::Agent { .. } => "liminal agent run --config <CONFIG>",
            Self::Seat { .. } => {
                "liminal seat init (--agent <AGENT> --key-file <KEY_FILE> | --open <SERVER>) [--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 {
        let config = liminal_server::config::file::load_config(path)?;
        // ACCESS-011 R3: the mode and, for open, its reach; for Lys, its decisions.
        let access = liminal_server::health::AccessHealth::from(&config);
        // ACCESS-013 R3: and where its agent registry is written.
        println!(
            "{}: configuration valid: {}, agents {}",
            path.display(),
            access.words(),
            config.agents_place()
        );
    } else if seat {
        let config = Config::load(path)?;
        let credential = match &config.credential {
            Credential::Open { .. } => format!("open, {}", config.credential.holder()),
            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: Option<&Path>, overrides: &liminal_cli::Overrides) -> ExitCode {
    let config = match Config::load_with(path, overrides) {
        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,
                open,
                name,
                out,
            },
    }) = &cli.command
    {
        let written = match (open, agent, key_file) {
            (Some(server), None, None) => {
                liminal_cli::seat::init_open_named(server, name.as_deref(), out)
            }
            (None, Some(agent), Some(key_file)) => liminal_cli::seat::init(agent, key_file, out),
            _ => {
                eprintln!("liminal seat init: give --agent with --key-file, or --open <SERVER>");
                return ExitCode::from(2);
            }
        };
        return match written {
            Ok(()) => {
                println!("{}: seat written", out.display());
                ExitCode::SUCCESS
            }
            Err(error) => {
                eprintln!("liminal seat init: {error}");
                ExitCode::from(1)
            }
        };
    }
    if let Some(Command::Mcp {
        server,
        channel,
        name,
        agent,
        key_file,
    }) = &cli.command
    {
        let overrides = liminal_cli::Overrides {
            server: server.clone(),
            channel: channel.clone(),
            name: name.clone(),
            agent: agent.clone(),
            key_file: key_file.clone(),
        };
        if cli.config.is_some() || !overrides.is_empty() {
            return run_mcp(cli.config.as_deref(), &overrides);
        }
    }
    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::Identity)) {
        return match identity::run(&config) {
            Ok(()) => ExitCode::SUCCESS,
            // ACCESS-013 R3: a seat file is answered with the seat's own request.
            Err(error) if error.is::<identity::SeatFile>() => {
                eprintln!("{error}");
                ExitCode::from(2)
            }
            Err(error) => {
                eprintln!("Error: {error:#}");
                ExitCode::from(1)
            }
        };
    }
    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)
        }
    }
}