hematite-cli 0.11.0

Senior SysAdmin, Network Admin, Data Analyst, and Software Engineer living in your terminal. A high-precision local AI agent harness for LM Studio, Ollama, and other local OpenAI-compatible runtimes that runs 100% on your own silicon. Reads repos, edits files, runs builds, inspects full network state and workstation telemetry, and runs real Python/JS for data analysis.
Documentation
use serde_json::Value;
use std::fmt::Write as _;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;

const DEFAULT_TIMEOUT_SECS: u64 = 300;
const MAX_OUTPUT_BYTES: usize = 131_072;

pub async fn run_hematite_maintainer_workflow(args: &Value) -> Result<String, String> {
    let workflow = args
        .get("workflow")
        .and_then(|value| value.as_str())
        .ok_or_else(|| "Missing required argument: 'workflow'".to_string())?;
    let invocation = ScriptInvocation::from_args(workflow, args)?;
    let output = execute_powershell_file(
        &invocation.script_path,
        &invocation.file_args,
        invocation.timeout_secs,
    )
    .await?;

    Ok(format!(
        "Hematite maintainer workflow: {}\nScript: {}\nCommand: {}\n\n{}",
        invocation.workflow_label,
        invocation.script_path.display(),
        invocation.display_command,
        output.trim()
    ))
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ScriptInvocation {
    workflow_label: &'static str,
    script_path: PathBuf,
    file_args: Vec<String>,
    display_command: String,
    timeout_secs: u64,
}

impl ScriptInvocation {
    fn from_args(workflow: &str, args: &Value) -> Result<Self, String> {
        match workflow {
            "clean" => build_clean_invocation(args),
            "package_windows" => build_package_windows_invocation(args),
            "release" => build_release_invocation(args),
            other => Err(format!(
                "Unknown workflow '{}'. Use one of: clean, package_windows, release.",
                other
            )),
        }
    }
}

fn build_clean_invocation(args: &Value) -> Result<ScriptInvocation, String> {
    let repo_root = require_repo_root()?;
    let mut file_args = Vec::with_capacity(4);
    if bool_arg(args, "deep") {
        file_args.push("-Deep".to_string());
    }
    if bool_arg(args, "reset") {
        file_args.push("-Reset".to_string());
    }
    if bool_arg(args, "prune_dist") {
        file_args.push("-PruneDist".to_string());
    }

    Ok(ScriptInvocation {
        workflow_label: "clean",
        script_path: repo_root.join("clean.ps1"),
        display_command: render_display_command(".\\clean.ps1", &file_args),
        file_args,
        timeout_secs: 180,
    })
}

fn build_package_windows_invocation(args: &Value) -> Result<ScriptInvocation, String> {
    ensure_windows("package_windows")?;
    let repo_root = require_repo_root()?;

    let mut file_args = Vec::with_capacity(4);
    if bool_arg(args, "installer") {
        file_args.push("-Installer".to_string());
    }
    if bool_arg(args, "add_to_path") {
        file_args.push("-AddToPath".to_string());
    }

    Ok(ScriptInvocation {
        workflow_label: "package_windows",
        script_path: repo_root.join("scripts").join("package-windows.ps1"),
        display_command: render_display_command(".\\scripts\\package-windows.ps1", &file_args),
        file_args,
        timeout_secs: 1800,
    })
}

fn build_release_invocation(args: &Value) -> Result<ScriptInvocation, String> {
    let repo_root = require_repo_root()?;
    let version = string_arg(args, "version");
    let bump = string_arg(args, "bump");
    if version.is_none() == bump.is_none() {
        return Err("workflow=release requires exactly one of: 'version' or 'bump'.".to_string());
    }

    let mut file_args = Vec::with_capacity(4);
    if let Some(version) = version {
        file_args.push("-Version".to_string());
        file_args.push(version);
    }
    if let Some(bump) = bump {
        match bump.as_str() {
            "patch" | "minor" | "major" => {
                file_args.push("-Bump".to_string());
                file_args.push(bump);
            }
            other => {
                return Err(format!(
                    "Invalid bump '{}'. Use one of: patch, minor, major.",
                    other
                ))
            }
        }
    }

    for (field, flag) in [
        ("push", "-Push"),
        ("add_to_path", "-AddToPath"),
        ("skip_installer", "-SkipInstaller"),
        ("publish_crates", "-PublishCrates"),
        ("publish_voice_crate", "-PublishVoiceCrate"),
    ] {
        if bool_arg(args, field) {
            file_args.push(flag.to_string());
        }
    }

    Ok(ScriptInvocation {
        workflow_label: "release",
        script_path: repo_root.join("release.ps1"),
        display_command: render_display_command(".\\release.ps1", &file_args),
        file_args,
        timeout_secs: 3600,
    })
}

fn bool_arg(args: &Value, key: &str) -> bool {
    args.get(key)
        .and_then(|value| value.as_bool())
        .unwrap_or(false)
}

fn string_arg(args: &Value, key: &str) -> Option<String> {
    args.get(key)
        .and_then(|value| value.as_str())
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(|value| value.to_string())
}

fn require_repo_root() -> Result<PathBuf, String> {
    find_hematite_repo_root().ok_or_else(|| {
        "Could not locate a Hematite source checkout for this maintainer workflow. Run Hematite from the Hematite repo, launch it from a portable that still lives under that repo's dist/ directory, or switch into the Hematite source workspace before retrying."
            .to_string()
    })
}

fn find_hematite_repo_root() -> Option<PathBuf> {
    let cwd_root = crate::tools::file_ops::workspace_root();
    if is_hematite_repo_root(&cwd_root) {
        return Some(cwd_root);
    }

    let exe = std::env::current_exe().ok()?;
    for ancestor in exe.ancestors() {
        let candidate = ancestor.to_path_buf();
        if is_hematite_repo_root(&candidate) {
            return Some(candidate);
        }
    }

    None
}

fn is_hematite_repo_root(path: &std::path::Path) -> bool {
    let cargo_toml = path.join("Cargo.toml");
    let clean = path.join("clean.ps1");
    let release = path.join("release.ps1");
    let package_windows = path.join("scripts").join("package-windows.ps1");
    if !cargo_toml.exists() || !clean.exists() || !release.exists() || !package_windows.exists() {
        return false;
    }

    let cargo_text = match fs::read_to_string(cargo_toml) {
        Ok(text) => text,
        Err(_) => return false,
    };

    cargo_text.contains("name = \"hematite-cli\"") || cargo_text.contains("name = \"hematite\"")
}

fn ensure_windows(workflow: &str) -> Result<(), String> {
    if cfg!(target_os = "windows") {
        Ok(())
    } else {
        Err(format!(
            "workflow={} is Windows-only because it depends on scripts/package-windows.ps1.",
            workflow
        ))
    }
}

fn render_display_command(script: &str, args: &[String]) -> String {
    if args.is_empty() {
        format!("pwsh {}", script)
    } else {
        format!("pwsh {} {}", script, args.join(" "))
    }
}

async fn execute_powershell_file(
    script_path: &std::path::Path,
    file_args: &[String],
    timeout_secs: u64,
) -> Result<String, String> {
    let cwd = require_repo_root()?;
    let shell = resolve_powershell_binary().await;
    let mut command = tokio::process::Command::new(&shell);
    command
        .arg("-NoProfile")
        .arg("-NonInteractive")
        .arg("-ExecutionPolicy")
        .arg("Bypass")
        .arg("-File")
        .arg(script_path)
        .args(file_args)
        .current_dir(&cwd)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped());

    let child_future = command.output();
    let output = match tokio::time::timeout(
        Duration::from_secs(timeout_secs.max(DEFAULT_TIMEOUT_SECS)),
        child_future,
    )
    .await
    {
        Ok(Ok(output)) => output,
        Ok(Err(err)) => {
            return Err(format!(
                "Failed to execute {}: {err}",
                script_path.display()
            ))
        }
        Err(_) => {
            return Err(format!(
                "Repo workflow timed out after {} seconds: {}",
                timeout_secs.max(DEFAULT_TIMEOUT_SECS),
                script_path.display()
            ))
        }
    };

    let stdout = cap_bytes(&output.stdout, MAX_OUTPUT_BYTES / 2);
    let stderr = cap_bytes(&output.stderr, MAX_OUTPUT_BYTES / 2);
    let exit_info = match output.status.code() {
        Some(0) => String::new(),
        Some(code) => format!("\n[exit code: {code}]"),
        None => "\n[process terminated by signal]".to_string(),
    };

    let mut result = String::with_capacity(stdout.len() + stderr.len() + 50);
    if !stdout.is_empty() {
        result.push_str(&stdout);
    }
    if !stderr.is_empty() {
        if !result.is_empty() {
            result.push('\n');
        }
        result.push_str("[stderr]\n");
        result.push_str(&stderr);
    }
    if result.is_empty() {
        result.push_str("(no output)");
    }
    result.push_str(&exit_info);
    Ok(crate::agent::utils::strip_ansi(&result))
}

async fn resolve_powershell_binary() -> String {
    if cfg!(target_os = "windows") && command_exists("pwsh").await {
        "pwsh".to_string()
    } else if cfg!(target_os = "windows") {
        "powershell".to_string()
    } else {
        "pwsh".to_string()
    }
}

async fn command_exists(name: &str) -> bool {
    let locator = if cfg!(target_os = "windows") {
        "where"
    } else {
        "which"
    };
    tokio::process::Command::new(locator)
        .arg(name)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .await
        .map(|status| status.success())
        .unwrap_or(false)
}

fn cap_bytes(bytes: &[u8], max: usize) -> String {
    if bytes.len() <= max {
        String::from_utf8_lossy(bytes).into_owned()
    } else {
        let mut s = String::from_utf8_lossy(&bytes[..max]).into_owned();
        let _ = write!(s, "\n... [truncated - {} bytes total]", bytes.len());
        s
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn clean_invocation_supports_deep_prune_dist() {
        let invocation = ScriptInvocation::from_args(
            "clean",
            &serde_json::json!({
                "workflow": "clean",
                "deep": true,
                "prune_dist": true
            }),
        )
        .expect("invocation");

        assert!(invocation.file_args.contains(&"-Deep".to_string()));
        assert!(invocation.file_args.contains(&"-PruneDist".to_string()));
        assert!(invocation.display_command.contains("clean.ps1"));
    }

    #[test]
    fn repo_root_detection_finds_the_hematite_checkout() {
        let root = require_repo_root().expect("repo root");
        assert!(root.join("Cargo.toml").exists());
        assert!(root.join("clean.ps1").exists());
    }

    #[test]
    fn release_invocation_requires_version_or_bump() {
        let err = ScriptInvocation::from_args(
            "release",
            &serde_json::json!({
                "workflow": "release"
            }),
        )
        .unwrap_err();
        assert!(err.contains("requires exactly one"));
    }

    #[test]
    fn release_invocation_builds_publish_flags() {
        let invocation = ScriptInvocation::from_args(
            "release",
            &serde_json::json!({
                "workflow": "release",
                "bump": "patch",
                "push": true,
                "add_to_path": true,
                "publish_crates": true
            }),
        )
        .expect("invocation");

        assert!(invocation.file_args.contains(&"-Bump".to_string()));
        assert!(invocation.file_args.contains(&"patch".to_string()));
        assert!(invocation.file_args.contains(&"-Push".to_string()));
        assert!(invocation.file_args.contains(&"-AddToPath".to_string()));
        assert!(invocation.file_args.contains(&"-PublishCrates".to_string()));
    }
}