use crate::{
Result,
common::{extension, plugin},
harness::{Context, Harness, Part, PartResult, Reads, Scope},
hook::{Answer, Event, HookInput, Output, ToolCall, claude_names},
integration::{Integration, Item},
};
#[derive(Debug, Clone, Copy, Default)]
pub struct Kilo;
const ID: &str = "kilo";
const LAYOUT: plugin::Layout = plugin::Layout {
harness: ID,
agent: "Kilo Code",
types: "@kilocode/plugin",
project_dir: ".kilo",
user_dir: ".config/kilo",
config: "kilo.json",
also_config: &["opencode.json"],
};
impl Harness for Kilo {
fn id(&self) -> &str {
ID
}
fn hook_events(&self) -> &[Event] {
&[
Event::SessionStart,
Event::PromptSubmit,
Event::PreTool,
Event::PostTool,
Event::Stop,
Event::PreCompact,
]
}
fn program(&self) -> Option<&str> {
Some("kilo")
}
fn claude_tool(&self, call: &ToolCall) -> ToolCall {
claude_names::named(call, plugin::claude_name(&call.name))
}
fn scopes(&self) -> &[Scope] {
&[Scope::Project, Scope::User]
}
fn render(&self, integration: &Integration, cx: &Context) -> Result<Vec<Part>> {
LAYOUT.render(integration, cx)
}
fn reads(&self, item: Item, cx: &Context) -> Result<Reads> {
Ok(LAYOUT.reads(item, cx))
}
fn parse_hook(&self, event: Event, text: &str) -> serde_json::Result<HookInput> {
extension::parse(ID, event, text, plugin::tool_kind)
}
fn answer(&self, event: Event, answer: &Answer) -> Result<Output> {
extension::answer(ID, event, answer)
}
fn notes(&self, _cx: &Context, parts: &[PartResult]) -> Vec<String> {
if parts.iter().any(PartResult::changed) {
vec!["restart Kilo Code to load the changes".into()]
} else {
Vec::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
harness::{Action, State},
integration::{Agent, Command, Hook, McpServer, Skill},
};
fn full() -> Integration {
Integration::new()
.instructions("Use t.\n")
.skill(Skill::new("t", "Use t.", "# T\n"))
.hook(Hook::new(Event::Stop, "t hook {harness} {event}"))
.mcp_server(McpServer::stdio("t", "t", ["mcp"]))
.allow_command("t")
.agent(Agent::new("r", "Reviews.", "Review.\n"))
.command(Command::new("c", "Checks.", "Check $ARGUMENTS.\n"))
}
#[test]
fn renders_into_kilos_paths() {
let names = |scope| -> Vec<String> {
let cx = Context::new("t", scope, "/nowhere", None);
Kilo.render(&full(), &cx)
.unwrap()
.iter()
.map(|p| format!("{} {}", p.name(), p.location()))
.collect()
};
assert_eq!(
names(Scope::Project),
[
"instructions AGENTS.md",
"skills .agents/skills",
"hooks .kilo/plugins",
"mcp kilo.json",
"permissions kilo.json",
"agents .kilo/agents",
"commands .kilo/commands"
]
);
assert_eq!(
names(Scope::User),
[
"instructions .config/kilo/AGENTS.md",
"skills .agents/skills",
"hooks .config/kilo/plugins",
"mcp .config/kilo/kilo.json",
"permissions .config/kilo/kilo.json",
"agents .config/kilo/agents",
"commands .config/kilo/commands"
]
);
let cx = Context::new("t", Scope::Project, "/nowhere", None);
assert_eq!(
Kilo.reads(Item::Mcp, &cx).unwrap(),
Reads::always(["kilo.json", "opencode.json"])
);
let ts = plugin::plugin(ID, "Kilo Code", "@kilocode/plugin", "t", &full());
assert!(ts.contains("\"t hook kilo stop\""), "{ts}");
assert_eq!(Kilo.scopes(), [Scope::Project, Scope::User]);
}
#[test]
fn hooks_and_notes() {
let i = Kilo
.parse_hook(Event::PreTool, r#"{"tool_name":"bash","session_id":"s"}"#)
.unwrap();
assert_eq!(i.harness, "kilo");
let deny = Kilo
.answer(Event::PreTool, &Answer::Deny { reason: "r".into() })
.unwrap();
assert_eq!(deny.stdout, r#"{"answer":"deny","reason":"r"}"#);
let part = |action| PartResult {
part: "hooks".into(),
state: State::Absent,
action,
path: String::new(),
by: None,
in_place: false,
};
let cx = Context::new("t", Scope::Project, "/nowhere", None);
assert!(Kilo.notes(&cx, &[part(None)]).is_empty());
assert_eq!(
Kilo.notes(&cx, &[part(Some(Action::Created))]),
["restart Kilo Code to load the changes"]
);
}
}