liminal-cli 0.3.1

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
//! The ticket forwards plan writes on its own pass, preserving refusal words.

use super::tools::ToolResult;
use crate::network::{
    Rail,
    call::{Outcome, request},
};
use serde_json::{Map, Value, json};
use std::io::Read;

const HEX: &[u8; 16] = b"0123456789abcdef";

/// Two plan tools and the site's deliberate acts share the existing call wire.
#[must_use]
pub fn tool_entries() -> Vec<Value> {
    vec![
        json!({"name":"plan_set","description":"Replace the building service's plan. Only its bound builder may write. Finished steps keep their ordinals and titles.",
            "inputSchema":{"type":"object","required":["service","steps"],"additionalProperties":false,
            "properties":{"service":{"type":"string"},"operation":{"type":"string"},"steps":{"type":"array","minItems":1,
                "items":{"type":"object","required":["title"],"additionalProperties":false,"properties":{"title":{"type":"string"}}}}}}}),
        json!({"name":"plan_step","description":"Change one ordinal step. At most one step may be doing. The server returns its exact refusal words.",
            "inputSchema":{"type":"object","required":["service","step","status"],"additionalProperties":false,
            "properties":{"service":{"type":"string"},"operation":{"type":"string"},"step":{"type":"integer","minimum":1},
                "status":{"enum":["todo","doing","done","blocked"]},"note":{"type":"string"}}}}),
        json!({"name":"create_site","description":"Propose a contracts-first construction site. A person approves its draft before the network changes.",
            "inputSchema":{"type":"object","required":["name","purpose","subjects","contracts"],"additionalProperties":false,
            "properties":{"name":{"type":"string"},"purpose":{"type":"string"},"operation":{"type":"string"},
                "subjects":{"type":"array","minItems":1,"items":{"type":"string"}},"contracts":{"type":"object",
                "additionalProperties":{"type":"object","required":["input_schema_json","output_schema_json"],"additionalProperties":false,
                    "properties":{"input_schema_json":{"type":"string"},"output_schema_json":{"type":"string"}}}}}}}),
        json!({"name":"bind_builder","description":"Propose exactly one active builder and its site-only plan and trial serve grants.",
            "inputSchema":{"type":"object","required":["service","agent"],"additionalProperties":false,
                "properties":{"service":{"type":"string"},"agent":{"type":"string"},"operation":{"type":"string"}}}}),
        json!({"name":"unbind_builder","description":"Propose revoking both recorded builder grants. Approval is required.",
            "inputSchema":{"type":"object","required":["service"],"additionalProperties":false,
                "properties":{"service":{"type":"string"},"operation":{"type":"string"}}}}),
        json!({"name":"complete","description":"Propose completing a site with an approved template and no registered trial. Approval revokes its trial serve grant.",
            "inputSchema":{"type":"object","required":["service"],"additionalProperties":false,
                "properties":{"service":{"type":"string"},"operation":{"type":"string"}}}}),
    ]
}

/// Handles only its own tools; it adds no local decision about a grant.
#[must_use]
pub fn tool<R: Rail>(rail: &R, name: &str, arguments: &Map<String, Value>) -> Option<ToolResult> {
    if !matches!(
        name,
        "plan_set" | "plan_step" | "create_site" | "bind_builder" | "unbind_builder" | "complete"
    ) {
        return None;
    }
    Some(forward(rail, name, arguments))
}

pub(super) fn forward<R: Rail>(rail: &R, name: &str, arguments: &Map<String, Value>) -> ToolResult {
    let mut payload = arguments.clone();
    let operation = match payload.remove("operation") {
        Some(Value::String(operation))
            if !operation.is_empty() && !operation.chars().any(char::is_control) =>
        {
            operation
        }
        Some(_) => {
            return ToolResult::failed(
                "operation must be a nonempty string without controls",
                json!({"tool":name}),
            );
        }
        None => {
            let mut bytes = [0_u8; 16];
            if std::fs::File::open("/dev/urandom")
                .and_then(|mut file| file.read_exact(&mut bytes))
                .is_err()
            {
                return ToolResult::failed(
                    "operation_randomness_unavailable",
                    json!({"tool":name}),
                );
            }
            let digits: String = bytes
                .iter()
                .flat_map(|byte| {
                    [
                        char::from(HEX[usize::from(byte >> 4)]),
                        char::from(HEX[usize::from(byte & 0x0f)]),
                    ]
                })
                .collect();
            format!("op-{digits}")
        }
    };
    let input = json!({"operation":operation,"payload":payload});
    match request(rail, &format!("services.{name}"), &input, true, None) {
        Outcome::Reply(answer) => {
            let Some(bound) = std::num::NonZeroUsize::new(rail.request_bytes()) else {
                return ToolResult::failed("ticket_request_bound_is_zero", json!({"tool":name}));
            };
            match super::held::from_wire(&answer, bound, bound) {
                Ok(Some(held)) => held,
                Ok(None) => ToolResult::ok(answer),
                Err(error) => ToolResult::failed(&error.to_string(), json!({"tool":name})),
            }
        }
        Outcome::Sent => ToolResult::failed("the server sent no reply", json!({"tool":name})),
        Outcome::Refused(words) => ToolResult::failed(&words, json!({"tool":name})),
    }
}

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