haematite-cli 0.21.1

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::collections::{BTreeMap, btree_map::Entry as MapEntry};
use std::path::{Path, PathBuf};

use serde_json::Value;

use super::agent::{Agent, LAUNCHER, NO_PASS};
use super::files::{Payload, bounded};
use super::types::{Error, Manifest};
use crate::mcp::schema::skill;
use crate::serve::cli::Config;

pub(super) struct Plan {
    pub payloads: BTreeMap<String, Payload>,
    pub manifest: Manifest,
}

impl Plan {
    pub(super) fn new(binary: &Path, config: &Path, bundle: &Path) -> Result<Self, Error> {
        Self::with_agent(binary, config, bundle, None)
    }

    pub(super) fn with_agent(
        binary: &Path,
        config: &Path,
        bundle: &Path,
        agent: Option<&Agent>,
    ) -> Result<Self, Error> {
        let bytes = bounded(config, "bundle_config_invalid")?;
        let settings: Config = serde_json::from_slice(&bytes)
            .map_err(|error| Error::new("bundle_config_invalid", &error))?;
        let mut value: Value = serde_json::from_slice(&bytes)
            .map_err(|error| Error::new("bundle_config_invalid", &error))?;
        if !settings.data_dir.is_absolute()
            || settings
                .socket
                .as_ref()
                .is_some_and(|socket| !socket.is_absolute())
        {
            return Err(Error::new(
                "bundle_config_invalid",
                "data_dir and socket must be absolute",
            ));
        }
        settings
            .append_prune_limits()
            .map_err(|error| Error::new("bundle_config_invalid", &error))?;
        settings
            .changes_settings()
            .map_err(|error| Error::new("bundle_config_invalid", &error))?;
        settings
            .retention
            .settings()
            .map_err(|error| Error::new("bundle_config_invalid", &error))?;
        let mut plan = Self {
            payloads: BTreeMap::new(),
            manifest: Manifest {
                format: 1,
                files: BTreeMap::new(),
            },
        };
        plan.add("bin/haematite", Payload::executable(binary)?, 0o700)?;
        if let Some(query) = settings
            .query_settings()
            .map_err(|error| Error::new("bundle_config_invalid", &error))?
        {
            plan.worker(
                "query_worker",
                "haematite-jq",
                &query.worker,
                bundle,
                &mut value,
            )?;
        }
        if let Some(embedding) = settings
            .embed_settings()
            .map_err(|error| Error::new("bundle_config_invalid", &error))?
        {
            if !Path::new(&embedding.model_dir).is_absolute() {
                return Err(Error::new(
                    "bundle_config_invalid",
                    "embed_model_dir must be absolute",
                ));
            }
            plan.worker(
                "embed_worker",
                "haematite-embed",
                &embedding.worker,
                bundle,
                &mut value,
            )?;
        }
        plan.add("serve.json", Payload::Bytes(json(&value)?), 0o600)?;
        plan.plugin_payloads(bundle, agent)?;
        Ok(plan)
    }

    fn plugin_payloads(&mut self, bundle: &Path, agent: Option<&Agent>) -> Result<(), Error> {
        let mut plugin = plugin(skill::MANIFEST)?;
        if let Some(agent) = agent {
            let server = &mut plugin["mcpServers"]["haematite"];
            server["command"] = Value::String("${CLAUDE_PLUGIN_ROOT}/scripts/mcp.sh".into());
            server
                .as_object_mut()
                .ok_or_else(|| {
                    Error::new(
                        "bundle_plugin_invalid",
                        "Haematite MCP server is not an object",
                    )
                })?
                .remove("args");
            server["env"] = agent.environment(bundle)?;
            self.add(
                "scripts/mcp.sh",
                Payload::Bytes(LAUNCHER.as_bytes().to_vec()),
                0o700,
            )?;
        }
        self.add(
            ".claude-plugin/plugin.json",
            Payload::Bytes(json(&plugin)?),
            0o600,
        )?;
        self.plugin_metadata(bundle, &plugin, agent)?;
        let page = if agent.is_some() {
            format!(
                "{}\n\nThe bundled MCP launcher presents the configured Lys agent's pass.\n",
                skill::PAGE.trim_end()
            )
        } else {
            format!("{}\n\n{NO_PASS}\n", skill::PAGE.trim_end())
        };
        self.add(
            "skills/haematite/SKILL.md",
            Payload::Bytes(page.into_bytes()),
            0o600,
        )?;
        Ok(())
    }

    fn plugin_metadata(
        &mut self,
        bundle: &Path,
        plugin: &Value,
        agent: Option<&Agent>,
    ) -> Result<(), Error> {
        let codex: Value = serde_json::from_str(skill::CODEX_MANIFEST)
            .map_err(|error| Error::new("bundle_plugin_invalid", &error))?;
        let mut mcp = serde_json::json!({
            "mcpServers": {"haematite": {
                "command": text(&bundle.join("bin/haematite"))?,
                "args": ["mcp", "--config", text(&bundle.join("serve.json"))?]
            }}
        });
        if let Some(agent) = agent {
            mcp["mcpServers"]["haematite"] = serde_json::json!({
                "command": text(&bundle.join("scripts/mcp.sh"))?,
                "env": agent.environment(bundle)?
            });
        }
        let claude_marketplace = serde_json::json!({
            "name": "haematite",
            "description": plugin["description"],
            "owner": {"name": "Ablative"},
            "plugins": [{
                "name": "haematite", "source": "./", "description": plugin["description"]
            }]
        });
        let codex_marketplace = serde_json::json!({
            "name": "haematite",
            "interface": {"displayName": "Haematite"},
            "plugins": [{
                "name": "haematite", "source": {"source": "local", "path": "./"},
                "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
                "category": "Productivity"
            }]
        });
        for (name, value) in [
            (".codex-plugin/plugin.json", codex),
            (".mcp.json", mcp),
            (".claude-plugin/marketplace.json", claude_marketplace),
            (".agents/plugins/marketplace.json", codex_marketplace),
        ] {
            self.add(name, Payload::Bytes(json(&value)?), 0o600)?;
        }
        Ok(())
    }

    fn worker(
        &mut self,
        field: &str,
        name: &str,
        source: &Path,
        bundle: &Path,
        config: &mut Value,
    ) -> Result<(), Error> {
        if !source.is_absolute() {
            return Err(Error::new(
                "bundle_source_invalid",
                "worker path must be absolute",
            ));
        }
        self.add(&format!("bin/{name}"), Payload::worker(source)?, 0o700)?;
        config[field] = Value::String(text(&bundle.join("bin").join(name))?.to_owned());
        Ok(())
    }

    fn add(&mut self, name: &str, mut payload: Payload, mode: u32) -> Result<(), Error> {
        match (
            self.manifest.files.entry(name.to_owned()),
            self.payloads.entry(name.to_owned()),
        ) {
            (MapEntry::Vacant(manifest), MapEntry::Vacant(slot)) => {
                let entry = payload.entry(mode)?;
                manifest.insert(entry);
                slot.insert(payload);
                Ok(())
            }
            _ => Err(Error::new("bundle_plan_invalid", "duplicate bundle path")),
        }
    }
}

fn plugin(source: &str) -> Result<Value, Error> {
    let mut plugin: Value = serde_json::from_str(source)
        .map_err(|error| Error::new("bundle_plugin_invalid", &error))?;
    let root = plugin
        .as_object_mut()
        .ok_or_else(|| Error::new("bundle_plugin_invalid", "plugin is not an object"))?;
    root.remove("userConfig");
    let server = root
        .get_mut("mcpServers")
        .and_then(|servers| servers.get_mut("haematite"))
        .and_then(Value::as_object_mut)
        .ok_or_else(|| {
            Error::new(
                "bundle_plugin_invalid",
                "Haematite MCP server is not an object",
            )
        })?;
    server.remove("env");
    server.insert(
        "command".to_owned(),
        Value::String("${CLAUDE_PLUGIN_ROOT}/bin/haematite".to_owned()),
    );
    server.insert(
        "args".to_owned(),
        serde_json::json!(["mcp", "--config", "${CLAUDE_PLUGIN_ROOT}/serve.json"]),
    );
    Ok(plugin)
}

pub(super) fn json(value: &impl serde::Serialize) -> Result<Vec<u8>, Error> {
    let mut bytes = serde_json::to_vec_pretty(value)
        .map_err(|error| Error::new("bundle_encoding_failed", &error))?;
    bytes.push(b'\n');
    Ok(bytes)
}

pub(super) fn text(path: &Path) -> Result<&str, Error> {
    path.to_str()
        .ok_or_else(|| Error::new("bundle_path_invalid", "bundle paths must be valid UTF-8"))
}

pub(super) fn destination(path: &Path) -> Result<PathBuf, Error> {
    let name = path
        .file_name()
        .ok_or_else(|| Error::new("bundle_path_invalid", "bundle must name a directory"))?;
    let parent = path
        .parent()
        .filter(|path| !path.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));
    let parent = std::fs::canonicalize(parent)
        .map_err(|error| Error::new("bundle_destination_invalid", &error))?;
    let destination = parent.join(name);
    text(&destination)?;
    Ok(destination)
}

#[cfg(test)]
#[path = "plan_tests.rs"]
mod tests;

#[cfg(test)]
mod runtime_endpoint_tests {
    use super::Plan;
    use crate::serve::cli::endpoint_tests::with_fixture;
    use std::collections::BTreeSet;
    use std::fs;
    use std::os::unix::fs::PermissionsExt;

    #[test]
    fn planning_without_a_socket_creates_no_namespace_or_files()
    -> Result<(), Box<dyn std::error::Error>> {
        with_fixture(|fixture| {
            fs::remove_dir(&fixture.data_dir)?;
            let binary = fixture.directory.path().join("operator");
            fs::write(&binary, b"operator")?;
            fs::set_permissions(&binary, fs::Permissions::from_mode(0o700))?;
            let bundle = fixture.directory.path().join("bundle");
            let entries = || -> Result<BTreeSet<_>, std::io::Error> {
                fs::read_dir(fixture.directory.path())?
                    .map(|entry| entry.map(|entry| entry.file_name()))
                    .collect()
            };
            let before = entries()?;
            let plan = Plan::new(&binary, &fixture.config_path, &bundle)?;
            assert_eq!(plan.payloads.len(), 8);
            assert_eq!(entries()?, before);
            assert!(!fixture.data_dir.try_exists()?);
            assert!(!bundle.try_exists()?);
            assert!(!fixture.directory.path().join("haem").try_exists()?);
            Ok(())
        })
    }

    #[test]
    fn planning_keeps_the_explicit_socket_absolute_path_check()
    -> Result<(), Box<dyn std::error::Error>> {
        with_fixture(|fixture| {
            let binary = fixture.directory.path().join("operator");
            fs::write(&binary, b"operator")?;
            fs::set_permissions(&binary, fs::Permissions::from_mode(0o700))?;
            let bundle = fixture.directory.path().join("bundle");
            let socket = fixture.directory.path().join("absent").join("service.sock");
            let mut value = fixture.value.clone();
            value["socket"] = serde_json::json!(socket);
            fs::write(&fixture.config_path, serde_json::to_vec(&value)?)?;
            let plan = Plan::new(&binary, &fixture.config_path, &bundle)?;
            assert_eq!(plan.payloads.len(), 8);
            assert!(
                !socket
                    .parent()
                    .ok_or("socket has no parent")?
                    .try_exists()?
            );
            assert!(!bundle.try_exists()?);
            assert!(!fixture.directory.path().join("haem").try_exists()?);
            value["socket"] = serde_json::json!("relative.sock");
            fs::write(&fixture.config_path, serde_json::to_vec(&value)?)?;
            let refusal = Plan::new(&binary, &fixture.config_path, &bundle)
                .err()
                .ok_or("relative socket was accepted")?;
            assert_eq!(refusal.code, "bundle_config_invalid");
            assert_eq!(refusal.message, "data_dir and socket must be absolute");
            Ok(())
        })
    }
}