feat(deploy): add colibri-deploy crate + MCP tools

New crate: colibri-deploy
- run(target, command) → shell on host or Bastille jail
- list_targets() → host + active Bastille jails (graceful sudo fallback)

New MCP tools (2):
- colibri_deploy_run: run a command on host or jail
- colibri_deploy_targets: list available targets

Tests: host echo, host fail, list_targets includes host.
Tool count: 18 → 20
This commit is contained in:
Sam & Claude 2026-06-27 18:55:09 +02:00
parent 9e8e25d535
commit cb124a014f
7 changed files with 147 additions and 2 deletions

8
Cargo.lock generated
View file

@ -355,6 +355,13 @@ dependencies = [
"tokio",
]
[[package]]
name = "colibri-deploy"
version = "0.12.0"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "colibri-glasspane"
version = "0.12.0"
@ -386,6 +393,7 @@ dependencies = [
"clap",
"colibri-client",
"colibri-daemon",
"colibri-deploy",
"colibri-pf",
"colibri-zfs",
"serde",

View file

@ -1,5 +1,5 @@
[workspace]
members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp", "crates/colibri-vault", "crates/colibri-zfs", "crates/colibri-pf", "crates/clawdie"]
members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp", "crates/colibri-vault", "crates/colibri-zfs", "crates/colibri-pf", "crates/colibri-deploy", "crates/clawdie"]
[workspace.package]
version = "0.12.0"

View file

@ -0,0 +1,8 @@
[package]
name = "colibri-deploy"
version.workspace = true
edition = "2021"
license = "MIT"
[dependencies]
thiserror = "2"

View file

@ -0,0 +1,92 @@
use std::process::Command;
/// Run a command on a target. Supports "host" (local shell) and Bastille jail names.
pub fn run(target: &str, command: &str) -> Result<String, Error> {
match target {
"host" => run_host(command),
jail => run_jail(jail, command),
}
}
/// List available deployment targets: "host" always present,
/// plus any Bastille jails reachable via passwordless sudo.
pub fn list_targets() -> Vec<String> {
let mut targets = vec!["host".to_string()];
if let Ok(output) = Command::new("sudo").args(["bastille", "list"]).output() {
if output.status.success() {
for line in String::from_utf8_lossy(&output.stdout).lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("JID") || line.starts_with('-') {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 {
targets.push(parts[0].to_string());
}
}
}
}
targets
}
fn run_host(command: &str) -> Result<String, Error> {
let output = Command::new("sh")
.args(["-c", command])
.output()
.map_err(|e| Error::Command(format!("host command: {e}")))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
return Err(Error::Command(format!(
"exit {}: {}",
output.status.code().unwrap_or(-1),
if stderr.is_empty() { stdout } else { stderr }
)));
}
Ok(if stdout.is_empty() { stderr } else { stdout })
}
fn run_jail(jail: &str, command: &str) -> Result<String, Error> {
let output = Command::new("sudo")
.args(["bastille", "cmd", jail, command])
.output()
.map_err(|e| Error::Command(format!("bastille cmd {jail}: {e}")))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let msg = if stderr.is_empty() { stdout } else { stderr };
return Err(Error::Command(format!("jail {jail}: {msg}")));
}
Ok(stdout)
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("deploy error: {0}")]
Command(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_host_echo() {
let result = run("host", "echo hello").unwrap();
assert_eq!(result.trim(), "hello");
}
#[test]
fn run_host_fail() {
assert!(run("host", "exit 1").is_err());
}
#[test]
fn list_targets_includes_host() {
let targets = list_targets();
assert!(targets.iter().any(|t| t == "host"));
}
}

View file

@ -14,6 +14,7 @@ colibri-client = { path = "../colibri-client" }
colibri-daemon = { path = "../colibri-daemon" }
colibri-pf = { path = "../colibri-pf" }
colibri-zfs = { path = "../colibri-zfs" }
colibri-deploy = { path = "../colibri-deploy" }
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View file

@ -207,6 +207,25 @@ pub fn tool_list() -> Vec<Value> {
"List active PF state table entries: protocol, source, destination, state.",
None,
),
// ── Deploy tools ──
json_tool(
"colibrie_deploy_run",
"Run a shell command on the host or in a Bastille jail. Use deploy_targets to list available targets.",
Some(serde_json::json!({
"type": "object",
"properties": {
"target": { "type": "string", "description": "Target: host or jail name from deploy_targets" },
"command": { "type": "string", "description": "Shell command to run" }
},
"required": ["target", "command"]
})),
),
json_tool(
"colibrie_deploy_targets",
"List deploy targets: host and available Bastille jails.",
None,
),
// ── Wiki tools ──
json_tool(
"colibri_wiki_search",
@ -433,6 +452,23 @@ pub async fn dispatch_tool(
serde_json::json!({"page": page, "content": content}),
))
}
// ── Deploy dispatch ──
"colibri_deploy_run" => {
let target = require_string(arguments, "target")?;
let command = require_string(arguments, "command")?;
let output = colibri_deploy::run(&target, &command)
.map_err(|e| McpError::internal(format!("deploy: {e}")))?;
Ok(tool_text(serde_json::json!({
"target": target,
"output": output,
})))
}
"colibri_deploy_targets" => {
let targets = colibri_deploy::list_targets();
Ok(tool_text(
serde_json::to_value(&targets).unwrap_or_default(),
))
}
"colibri_external_mcp_servers" => {
let registry = external::load_registry_if_present(&config.external_config_path).await?;
Ok(tool_text(serde_json::json!({

View file

@ -255,5 +255,5 @@ fn tool_list_has_all_phase1_tools() {
assert!(names.contains(&"colibri_list_task_costs"));
assert!(names.contains(&"colibri_get_task"));
assert_eq!(names.len(), 18);
assert_eq!(names.len(), 20);
}