agentic_ssh 0.2.2

**High-performance MCP server enabling AI agents to securely execute commands on remote SSH hosts** `agentic_ssh` is a feature-rich Model Context Protocol (MCP) server that empowers AI agents with seamless, secure remote system access. It automatically parses SSH configs, manages connection pools with idle timeout cleanup, and provides 12+ built-in tools for remote execution, monitoring, and system inspection. Supports 15+ AI agents with one-command auto-installation. Built with async Rust (tokio + russh) and mimalloc for maximum performance.
// Rust guideline compliant 2025-10-17
//! Moonshot Kimi CLI agent integration.
//!
//! Registers the agentic_ssh MCP server in Kimi's `~/.kimi/mcp.json`
//! (standard `mcpServers` JSON schema, same shape as Claude/Cursor) and
//! appends prompt rules to `~/.kimi/AGENTS.md`. Kimi has no hook system
//! and no per-tool auto-approval — approval is handled globally via
//! Kimi's YOLO / AFK modes.

use std::io::Write;
use std::path::Path;

use serde_json::json;

use crate::errors::{AgenticSshError, Result};

use super::{
    AgentIntegration, DoctorCounters, HealthcheckContext, InstallContext, backup_and_write_json,
    backup_config_file, load_json_file, load_json_file_strict, safe_write_json_file,
};

/// Moonshot Kimi CLI agent.
pub struct KimiIntegration;

impl AgentIntegration for KimiIntegration {
    fn name(&self) -> &'static str {
        "Kimi CLI"
    }

    fn id(&self) -> &'static str {
        "kimi"
    }

    fn install(&self, ctx: &InstallContext) -> Result<()> {
        let kimi_dir = ctx.home.join(".kimi");
        std::fs::create_dir_all(&kimi_dir).ok();

        let mcp_path = kimi_dir.join("mcp.json");
        install_mcp_server(&mcp_path, &ctx.agentic_ssh_bin)?;

        let agents_md = kimi_dir.join("AGENTS.md");
        install_prompt_rules(&agents_md)?;

        eprintln!();
        eprintln!("Setup complete. Next steps:");
        eprintln!("  1. Start a new Kimi session — agentic_ssh tools are now available");
        Ok(())
    }

    fn uninstall(&self, ctx: &InstallContext) -> Result<()> {
        let kimi_dir = ctx.home.join(".kimi");
        let mcp_path = kimi_dir.join("mcp.json");
        uninstall_mcp_server(&mcp_path);

        let agents_md = kimi_dir.join("AGENTS.md");
        uninstall_prompt_rules(&agents_md);

        eprintln!();
        eprintln!("Uninstall complete. AgenticSsh has been removed from Kimi CLI.");
        eprintln!("Start a new Kimi session for changes to take effect.");
        Ok(())
    }

    fn healthcheck(&self, dc: &mut DoctorCounters, ctx: &HealthcheckContext) {
        eprintln!("\n\x1b[1mKimi CLI integration\x1b[0m");
        let kimi_dir = ctx.home.join(".kimi");
        doctor_check_mcp(dc, &kimi_dir.join("mcp.json"));
        doctor_check_prompt(dc, &kimi_dir);
    }

    fn is_detected(&self, home: &Path) -> bool {
        home.join(".kimi").is_dir()
    }

    fn primary_config_path(&self, home: &Path) -> Option<std::path::PathBuf> {
        Some(home.join(".kimi/mcp.json"))
    }

    fn has_agentic_ssh(&self, home: &Path) -> bool {
        let mcp_path = home.join(".kimi/mcp.json");
        if !mcp_path.exists() {
            return false;
        }
        let json = load_json_file(&mcp_path);
        if let Some(agentic_ssh) = json.get("mcpServers").and_then(|v| v.get("agentic_ssh")) {
            if let Some(current_bin) = super::which_agentic_ssh() {
                let cmd = agentic_ssh
                    .get("command")
                    .and_then(|v| v.as_str())
                    .unwrap_or("");
                cmd == current_bin
            } else {
                true
            }
        } else {
            false
        }
    }
}

// ---------------------------------------------------------------------------
// Install helpers
// ---------------------------------------------------------------------------

/// Register agentic_ssh under `mcpServers` in `~/.kimi/mcp.json`.
fn install_mcp_server(mcp_path: &Path, agentic_ssh_bin: &str) -> Result<()> {
    let backup = backup_config_file(mcp_path)?;
    let mut settings = match load_json_file_strict(mcp_path) {
        Ok(v) => v,
        Err(e) => {
            if let Some(ref b) = backup {
                eprintln!("  Backup preserved at: {}", b.display());
            }
            return Err(e);
        }
    };

    settings["mcpServers"]["agentic_ssh"] = json!({
        "command": agentic_ssh_bin,
        "args": ["serve"]
    });

    safe_write_json_file(mcp_path, &settings, backup.as_deref())?;
    eprintln!(
        "\x1b[32m✔\x1b[0m Added agentic_ssh MCP server to {}",
        mcp_path.display()
    );
    Ok(())
}

/// Append prompt rules to AGENTS.md (idempotent).
fn install_prompt_rules(agents_md: &Path) -> Result<()> {
    let marker = "## Prefer agentic_ssh MCP tools";
    let existing = if agents_md.exists() {
        std::fs::read_to_string(agents_md).unwrap_or_default()
    } else {
        String::new()
    };
    if existing.contains(marker) {
        if existing.contains("agentic_ssh_context") || existing.contains("knowledge graph") {
            uninstall_prompt_rules(agents_md);
        } else {
            eprintln!("  AGENTS.md already contains agentic_ssh rules, skipping");
            return Ok(());
        }
    }
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(agents_md)
        .map_err(|e| AgenticSshError::Config {
            message: format!("failed to open AGENTS.md: {e}"),
        })?;
    write!(
        f,
        "\n{marker}\n\n\
        When you need to discover, query, monitor, or execute commands on remote SSH hosts, \
        ALWAYS use the `agentic_ssh` MCP tools:\n\
        - **Discovering Hosts:** Use the `list_hosts` tool to retrieve the list of configured remote SSH hosts. Do NOT read or parse `~/.ssh/config` manually.\n\
        - **Executing Commands:** Use the `run_command` tool to run shell commands on one or more hosts concurrently.\n\
        - **Monitoring Logs:** Use `tail_log` (for files) or `tail_container_logs` (for Docker containers) to read recent logs. To verify startup, services, or events across cluster nodes without polling, use `wait_for_log_pattern` to block and stream logs until a regex pattern is matched.\n\
        - **Checking System & Network Status:** Use `get_system_stats` to fetch structured CPU, memory, and disk usage metrics. Use `list_ports` to see active listening TCP/UDP ports. Use `search_processes` to find running processes.\n\
        - **Custom Tools:** Use custom commands registered dynamically through the configuration file (e.g., `find_large_files`, `check_service_status`, `check_docker_status`).\n\n\
        These tools leverage an automatic connection pool (reusing active sessions and closing them after 5 minutes of inactivity), handle SSH key-based authentication seamlessly, and support output abbreviation to prevent token bloat.\n"
    )
    .ok();
    eprintln!(
        "\x1b[32m✔\x1b[0m Appended agentic_ssh rules to {}",
        agents_md.display()
    );
    Ok(())
}

// ---------------------------------------------------------------------------
// Uninstall helpers
// ---------------------------------------------------------------------------

/// Remove agentic_ssh from `~/.kimi/mcp.json`.
fn uninstall_mcp_server(mcp_path: &Path) {
    if !mcp_path.exists() {
        eprintln!("  {} not found, skipping", mcp_path.display());
        return;
    }

    let Ok(contents) = std::fs::read_to_string(mcp_path) else {
        return;
    };
    let Ok(mut settings) = serde_json::from_str::<serde_json::Value>(&contents) else {
        return;
    };

    let Some(servers) = settings
        .get_mut("mcpServers")
        .and_then(|v| v.as_object_mut())
    else {
        eprintln!(
            "  No agentic_ssh MCP server in {}, skipping",
            mcp_path.display()
        );
        return;
    };

    if servers.remove("agentic_ssh").is_none() {
        eprintln!(
            "  No agentic_ssh MCP server in {}, skipping",
            mcp_path.display()
        );
        return;
    }

    let is_empty = settings.as_object().is_some_and(|o| {
        o.iter()
            .all(|(k, v)| k == "mcpServers" && v.as_object().is_some_and(serde_json::Map::is_empty))
    });

    if is_empty {
        std::fs::remove_file(mcp_path).ok();
        eprintln!(
            "\x1b[32m✔\x1b[0m Removed {} (was empty)",
            mcp_path.display()
        );
    } else if backup_and_write_json(mcp_path, &settings) {
        eprintln!(
            "\x1b[32m✔\x1b[0m Removed agentic_ssh MCP server from {}",
            mcp_path.display()
        );
    }
}

/// Remove agentic_ssh rules from AGENTS.md.
fn uninstall_prompt_rules(agents_md: &Path) {
    if !agents_md.exists() {
        return;
    }
    let Ok(contents) = std::fs::read_to_string(agents_md) else {
        return;
    };
    if !contents.contains("agentic_ssh") {
        eprintln!("  AGENTS.md does not contain agentic_ssh rules, skipping");
        return;
    }
    let marker = "## Prefer agentic_ssh MCP tools";
    let Some(start) = contents.find(marker) else {
        return;
    };
    let after_marker = start + marker.len();
    let end = contents[after_marker..]
        .find("\n## ")
        .map_or(contents.len(), |pos| after_marker + pos);
    let mut new_contents = String::new();
    new_contents.push_str(contents[..start].trim_end());
    let remainder = &contents[end..];
    if !remainder.is_empty() {
        new_contents.push_str("\n\n");
        new_contents.push_str(remainder.trim_start());
    }
    let new_contents = new_contents.trim().to_string();
    if new_contents.is_empty() {
        std::fs::remove_file(agents_md).ok();
        eprintln!(
            "\x1b[32m✔\x1b[0m Removed {} (was empty)",
            agents_md.display()
        );
    } else {
        std::fs::write(agents_md, format!("{new_contents}\n")).ok();
        eprintln!(
            "\x1b[32m✔\x1b[0m Removed agentic_ssh rules from {}",
            agents_md.display()
        );
    }
}

// ---------------------------------------------------------------------------
// Healthcheck helpers
// ---------------------------------------------------------------------------

/// Check `~/.kimi/mcp.json` has agentic_ssh registered.
fn doctor_check_mcp(dc: &mut DoctorCounters, mcp_path: &Path) {
    if !mcp_path.exists() {
        dc.warn(&format!(
            "{} not found — run `agentic_ssh install --agent kimi` if you use Kimi CLI",
            mcp_path.display()
        ));
        return;
    }
    let settings = load_json_file(mcp_path);
    let server = settings
        .get("mcpServers")
        .and_then(|v| v.get("agentic_ssh"));
    if server.and_then(|v| v.as_object()).is_some() {
        dc.pass(&format!("MCP server registered in {}", mcp_path.display()));
    } else {
        dc.fail(&format!(
            "MCP server NOT registered in {} — run `agentic_ssh install --agent kimi`",
            mcp_path.display()
        ));
    }
}

/// Check AGENTS.md contains agentic_ssh rules.
fn doctor_check_prompt(dc: &mut DoctorCounters, kimi_dir: &Path) {
    let agents_md = kimi_dir.join("AGENTS.md");
    if agents_md.exists() {
        let has_rules = std::fs::read_to_string(&agents_md)
            .unwrap_or_default()
            .contains("agentic_ssh");
        if has_rules {
            dc.pass("AGENTS.md contains agentic_ssh rules");
        } else {
            dc.fail("AGENTS.md missing agentic_ssh rules — run `agentic_ssh install --agent kimi`");
        }
    } else {
        dc.warn("~/.kimi/AGENTS.md does not exist");
    }
}