use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, value};
use super::cchooks::hook_is_portable;
use super::confedit;
use super::mcpjson::{self, RemoteShape, ServerShape};
use super::report;
use super::skillsdir;
use super::{AgentBackend, BackendState};
use crate::components::HookBinding;
use crate::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use crate::error::{Error, Result};
use crate::host::{Capabilities, Desired, Outcome, Plugin, Scope, Source};
pub(crate) struct KimiBackend;
const SHAPE: ServerShape = ServerShape::plain().with_remote(RemoteShape::TransportKeyed);
impl AgentBackend for KimiBackend {
fn id(&self) -> &'static str {
"kimi"
}
fn detect(&self) -> bool {
kimi_home_opt().is_some_and(|d| d.is_dir())
}
fn capabilities(&self) -> Capabilities {
Capabilities {
plugins: false,
mcp: true,
hooks: true,
commands: false,
agents: false,
skills: true,
instructions: false,
scopes: &["user"],
}
}
fn probe(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<BackendState> {
let comp = plugin.components(source)?.with_client(self.id());
let base = kimi_base(scope)?;
let mcp = mcpjson::probe_surface(&base.join("mcp.json"), &["mcpServers"], &comp.mcp_servers, SHAPE)?;
let hooks = probe_hooks(&base.join("config.toml"), &comp.hooks)?;
let skills = skillsdir::probe(&base.join("skills"), plugin, &comp.skills)?;
Ok(report::compose([mcp, hooks, skills].into_iter().flatten()))
}
fn reconcile(&self, plugin: &Plugin, desired: &Desired, scope: &Scope) -> Result<Outcome> {
let comp = plugin.components(&desired.source)?.with_client(self.id());
let base = kimi_base(scope)?;
let mut changed = false;
changed |= mcpjson::reconcile(&base.join("mcp.json"), &["mcpServers"], &comp.mcp_servers, SHAPE)? != Outcome::NoOp;
changed |= reconcile_hooks(&base.join("config.toml"), &comp.hooks)?;
changed |= skillsdir::reconcile(&base.join("skills"), plugin, &comp.skills)?;
Ok(if changed { Outcome::Installed } else { Outcome::NoOp })
}
fn remove(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<Outcome> {
let comp = plugin.components(source)?.with_client(self.id());
let base = kimi_base(scope)?;
let mut changed = false;
changed |= mcpjson::remove(&base.join("mcp.json"), &["mcpServers"], &comp.mcp_servers, SHAPE)? != Outcome::NoOp;
changed |= remove_hooks(&base.join("config.toml"), &comp.hooks)?;
changed |= skillsdir::remove(&base.join("skills"), plugin, &comp.skills)?;
Ok(if changed { Outcome::Removed } else { Outcome::NoOp })
}
fn report(&self, plugin: &Plugin, source: &Source) -> DoctorReport {
DoctorReport::from_checks(report_checks(self, plugin, source))
}
}
fn kimi_home_opt() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("KIMI_CODE_HOME").filter(|v| !v.is_empty()) {
return Some(PathBuf::from(dir));
}
dirs::home_dir().map(|h| h.join(".kimi-code"))
}
fn kimi_home() -> Result<PathBuf> {
kimi_home_opt().ok_or_else(|| Error::Tree("no home directory (HOME and KIMI_CODE_HOME unset); cannot locate ~/.kimi-code".into()))
}
fn kimi_base(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => kimi_home(),
Scope::Project { path } => Ok(path.join(".kimi-code")),
}
}
const KIMI_EVENTS: &[&str] = &[
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"Stop",
"StopFailure",
"PermissionRequest",
"PermissionResult",
"SessionStart",
"SessionEnd",
"SubagentStart",
"SubagentStop",
"Interrupt",
"PreCompact",
"PostCompact",
"Notification",
];
fn map_event(cc_event: &str) -> Option<&'static str> {
KIMI_EVENTS.iter().copied().find(|e| *e == cc_event)
}
fn render_hook_table(event: &str, hook: &HookBinding) -> Table {
let mut t = Table::new();
t.insert("event", value(event));
if let Some(matcher) = &hook.matcher {
t.insert("matcher", value(matcher.as_str()));
}
t.insert("command", value(hook.command.as_str()));
t
}
fn hooks_array(doc: &mut DocumentMut) -> Option<&mut ArrayOfTables> {
doc.as_table_mut().entry("hooks").or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())).as_array_of_tables_mut()
}
fn hook_present(arr: &ArrayOfTables, event: &str, hook: &HookBinding) -> bool {
arr.iter().any(|t| {
t.get("event").and_then(Item::as_str) == Some(event)
&& t.get("command").and_then(Item::as_str) == Some(hook.command.as_str())
&& t.get("matcher").and_then(Item::as_str) == hook.matcher.as_deref()
})
}
fn reconcile_hooks(config: &Path, hooks: &[HookBinding]) -> Result<bool> {
let writable: Vec<(&'static str, &HookBinding)> =
hooks.iter().filter(|h| hook_is_portable(h)).filter_map(|h| map_event(&h.event).map(|e| (e, h))).collect();
if writable.is_empty() {
return Ok(false);
}
confedit::toml_edit(config, |doc| {
let Some(arr) = hooks_array(doc) else {
return Ok(()); };
for (event, hook) in &writable {
if !hook_present(arr, event, hook) {
arr.push(render_hook_table(event, hook));
}
}
Ok(())
})
}
fn probe_hooks(config: &Path, hooks: &[HookBinding]) -> Result<Option<BackendState>> {
let writable: Vec<(&'static str, &HookBinding)> =
hooks.iter().filter(|h| hook_is_portable(h)).filter_map(|h| map_event(&h.event).map(|e| (e, h))).collect();
if writable.is_empty() {
return Ok(None);
}
let text = match fs::read_to_string(config) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Some(BackendState::Absent)),
Err(source) => return Err(Error::Io { context: format!("reading {}", config.display()), source }),
};
let doc: DocumentMut =
text.parse().map_err(|e: toml_edit::TomlError| Error::Config { path: config.display().to_string(), detail: e.to_string() })?;
let arr = doc.get("hooks").and_then(Item::as_array_of_tables);
let present = writable.iter().filter(|(event, hook)| arr.is_some_and(|a| hook_present(a, event, hook))).count();
Ok(Some(if present == 0 {
BackendState::Absent
} else if present == writable.len() {
BackendState::Healthy
} else {
BackendState::NeedsRepair
}))
}
fn remove_hooks(config: &Path, hooks: &[HookBinding]) -> Result<bool> {
if !config.exists() {
return Ok(false);
}
let ours: BTreeSet<&str> = hooks.iter().filter(|h| hook_is_portable(h)).map(|h| h.command.as_str()).collect();
confedit::toml_remove(config, |doc| {
confedit::toml_prune(doc, "hooks", |item| {
if let Some(arr) = item.as_array_of_tables_mut() {
arr.retain(|t| t.get("command").and_then(Item::as_str).is_none_or(|c| !ours.contains(c)));
}
Ok(())
})
})
}
fn report_checks(backend: &KimiBackend, plugin: &Plugin, source: &Source) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(if backend.detect() {
DoctorCheck { name: "kimi detected", status: CheckStatus::Ok("~/.kimi-code present".into()) }
} else {
DoctorCheck {
name: "kimi detected",
status: CheckStatus::Fail {
problem: "kimi-code not detected (~/.kimi-code absent)".into(),
fix: "install it with `npm install -g @moonshot-ai/kimi-code`, then run `kimi` once".into(),
},
}
});
let base = match kimi_base(&Scope::User) {
Ok(base) => base,
Err(e) => {
checks.push(DoctorCheck { name: "mcp config file", status: CheckStatus::Warn(e.to_string()) });
return checks;
}
};
let mcp = base.join("mcp.json");
let root = report::read_json_config(&mut checks, "mcp config file", &mcp);
let Some(comp) = report::components(&mut checks, plugin, source).map(|c| c.with_client(backend.id())) else {
return checks;
};
checks.push(report::check_mcp_registered(
&comp.mcp_servers,
root.as_ref(),
&["mcpServers"],
"not in mcp.json",
"run the host's `setup`",
));
checks.push(report::check_mcp_command(&comp.mcp_servers));
checks.push(check_hooks_present(&comp.hooks, &base.join("config.toml")));
checks
}
fn check_hooks_present(hooks: &[HookBinding], config: &Path) -> DoctorCheck {
let name = "translated hooks present";
let skipped = report::skipped_hooks(hooks);
let ours: Vec<&str> =
hooks.iter().filter(|h| hook_is_portable(h) && map_event(&h.event).is_some()).map(|h| h.command.as_str()).collect();
if ours.is_empty() {
return report::note_skipped(DoctorCheck { name, status: CheckStatus::Ok("no hooks to translate".into()) }, &skipped);
}
let text = fs::read_to_string(config).unwrap_or_default();
let missing: Vec<&str> = ours.iter().copied().filter(|c| !text.contains(c)).collect();
let check = if missing.is_empty() {
DoctorCheck { name, status: CheckStatus::Ok(format!("{} hook(s) present in config.toml", ours.len())) }
} else {
DoctorCheck {
name,
status: CheckStatus::Fail {
problem: format!("hook(s) missing from config.toml: {}", missing.join(", ")),
fix: "run the host's `setup`".into(),
},
}
};
report::note_skipped(check, &skipped)
}
#[cfg(test)]
#[path = "../../tests/unit/kimi.rs"]
mod kimi_tests;