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(())
})
}
}