haematite-cli 0.21.3

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
use std::path::{Path, PathBuf};

use serde_json::{Value, json};
use ureq::http::Uri;

use super::plan::text;
use super::types::Error;

pub(super) const LAUNCHER: &str = include_str!(concat!(
    env!("CARGO_MANIFEST_DIR"),
    "/embedded/plugin/scripts/mcp.sh"
));
pub(super) const NO_PASS: &str =
    "No Lys agent pass is configured. Calls to a store behind Lys will refuse pass_missing.";

#[derive(Debug, PartialEq, Eq)]
pub(super) struct Agent {
    issuer: String,
    id: String,
    key: PathBuf,
}

impl Agent {
    pub(super) fn parse(
        issuer: Option<PathBuf>,
        id: Option<PathBuf>,
        key: Option<PathBuf>,
    ) -> Result<Option<Self>, Error> {
        if issuer.is_none() && id.is_none() && key.is_none() {
            return Ok(None);
        }
        let issuer = issuer.unwrap_or_else(|| "https://lys.ablative.com.au".into());
        let (Some(issuer), Some(id), Some(key)) = (Some(issuer), id, key) else {
            return Err(Error::new(
                "bundle_lys_agent_incomplete",
                "--lys-agent and --key-file must be given together; --lys-issuer defaults to the central origin",
            ));
        };
        let issuer = text(&issuer)?;
        let origin: Uri = issuer.parse().map_err(|_| invalid("invalid Lys origin"))?;
        let authority = origin
            .authority()
            .ok_or_else(|| invalid("invalid Lys origin"))?;
        let local = matches!(authority.host(), "127.0.0.1" | "localhost");
        if !issuer.is_ascii()
            || issuer.contains('#')
            || authority.as_str().contains('@')
            || !(origin.scheme_str() == Some("https")
                || (origin.scheme_str() == Some("http") && local))
            || origin.query().is_some()
            || !matches!(origin.path(), "" | "/")
        {
            return Err(invalid(
                "Lys issuer must be an HTTPS origin, or HTTP on loopback",
            ));
        }
        let id = text(&id)?;
        if id.is_empty()
            || id.len() > 128
            || !id
                .bytes()
                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
        {
            return Err(invalid("--lys-agent must be a plain agent id"));
        }
        let key = std::fs::canonicalize(key)
            .map_err(|error| Error::new("bundle_lys_key_invalid", &error.kind()))?;
        if !key.is_file() {
            return Err(invalid("--key-file must name a regular file"));
        }
        text(&key)?;
        Ok(Some(Self {
            issuer: issuer.to_owned(),
            id: id.to_owned(),
            key,
        }))
    }

    pub(super) fn environment(&self, bundle: &Path) -> Result<Value, Error> {
        Ok(json!({
            "HAEMATITE_BIN": text(&bundle.join("bin/haematite"))?,
            "HAEMATITE_URL": "https://haematite.ablative.com.au/mcp",
            "HAEMATITE_LYS_ISSUER": self.issuer,
            "HAEMATITE_LYS_AGENT": self.id,
            "HAEMATITE_KEY_FILE": text(&self.key)?
        }))
    }
}

fn invalid(message: &str) -> Error {
    Error::new("bundle_lys_agent_invalid", message)
}