1use std::path::Path;
13use std::path::PathBuf;
14
15use crate::agents::planning as agent_planning;
16use crate::error::AgentConfigError;
17use crate::integration::{
18 InstallReport, InstructionSurface, Integration, McpSurface, SkillSurface, UninstallReport,
19};
20use crate::paths;
21use crate::plan::{InstallPlan, UninstallPlan};
22use crate::scope::{Scope, ScopeKind};
23use crate::spec::{HookSpec, InstructionSpec, McpSpec, SkillSpec};
24use crate::status::StatusReport;
25use crate::util::{instructions_dir, mcp_json_map, ownership, rules_dir, skills_dir};
26
27const RULES_DIR: &str = ".kilo/rules";
28
29#[derive(Debug, Clone, Copy, Default)]
31pub struct KiloCodeAgent {
32 _private: (),
33}
34
35impl KiloCodeAgent {
36 pub const fn new() -> Self {
38 Self { _private: () }
39 }
40
41 fn require_local<'a>(&self, scope: &'a Scope) -> Result<&'a Path, AgentConfigError> {
42 match scope {
43 Scope::Local(p) => Ok(p),
44 Scope::Global => Err(AgentConfigError::UnsupportedScope {
45 id: "kilocode",
46 scope: ScopeKind::Global,
47 }),
48 }
49 }
50
51 fn local_mcp_path(root: &Path) -> PathBuf {
52 let dot_kilo = root.join(".kilo").join("kilo.jsonc");
53 if dot_kilo.exists() {
54 dot_kilo
55 } else {
56 root.join("kilo.jsonc")
57 }
58 }
59
60 fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
61 Ok(match scope {
62 Scope::Global => paths::kilo_config_file()?,
63 Scope::Local(p) => Self::local_mcp_path(p),
64 })
65 }
66
67 fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
68 Ok(match scope {
69 Scope::Global => paths::home_dir()?.join(".kilo").join("skills"),
70 Scope::Local(p) => p.join(".kilo").join("skills"),
71 })
72 }
73}
74
75impl Integration for KiloCodeAgent {
76 fn id(&self) -> &'static str {
77 "kilocode"
78 }
79
80 fn display_name(&self) -> &'static str {
81 "Kilo Code"
82 }
83
84 fn supported_scopes(&self) -> &'static [ScopeKind] {
85 &[ScopeKind::Local]
86 }
87
88 fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
89 HookSpec::validate_tag(tag)?;
90 let root = self.require_local(scope)?;
91 let path = rules_dir::target_path(root, RULES_DIR, tag);
92 Ok(StatusReport::for_file_hook(tag, path))
93 }
94
95 fn plan_install(
96 &self,
97 scope: &Scope,
98 spec: &HookSpec,
99 ) -> Result<InstallPlan, AgentConfigError> {
100 agent_planning::rules_install(
101 Integration::id(self),
102 scope,
103 spec,
104 self.require_local(scope),
105 RULES_DIR,
106 )
107 }
108
109 fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
110 agent_planning::rules_uninstall(
111 Integration::id(self),
112 scope,
113 tag,
114 self.require_local(scope),
115 RULES_DIR,
116 )
117 }
118
119 fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
120 HookSpec::validate_tag(&spec.tag)?;
121 let _ = self.require_local(scope)?;
123 agent_planning::validate_prompt_only_event(Integration::id(self), &spec.event)?;
124 let rules = spec
125 .rules
126 .as_ref()
127 .ok_or(AgentConfigError::MissingSpecField {
128 id: "kilocode",
129 field: "rules",
130 })?;
131 rules_dir::install(scope, RULES_DIR, &spec.tag, &rules.content)
132 }
133
134 fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
135 HookSpec::validate_tag(tag)?;
136 let _ = self.require_local(scope)?;
137 rules_dir::uninstall(scope, RULES_DIR, tag)
138 }
139}
140
141impl McpSurface for KiloCodeAgent {
142 fn id(&self) -> &'static str {
143 "kilocode"
144 }
145
146 fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
147 &[ScopeKind::Global, ScopeKind::Local]
148 }
149
150 fn mcp_status(
151 &self,
152 scope: &Scope,
153 name: &str,
154 expected_owner: &str,
155 ) -> Result<StatusReport, AgentConfigError> {
156 McpSpec::validate_name(name)?;
157 let cfg = Self::mcp_path(scope)?;
158 let ledger = ownership::mcp_ledger_for(&cfg);
159 let presence =
160 mcp_json_map::config_presence(&cfg, &["mcp"], name, mcp_json_map::ConfigFormat::Jsonc)?;
161 let recorded = ownership::owner_of(&ledger, name)?;
162 Ok(StatusReport::for_mcp(
163 name,
164 cfg,
165 ledger,
166 presence,
167 expected_owner,
168 recorded,
169 ))
170 }
171
172 fn plan_install_mcp(
173 &self,
174 scope: &Scope,
175 spec: &McpSpec,
176 ) -> Result<InstallPlan, AgentConfigError> {
177 agent_planning::mcp_json_map_install(
178 McpSurface::id(self),
179 scope,
180 spec,
181 Self::mcp_path(scope),
182 &["mcp"],
183 mcp_json_map::command_array_value,
184 mcp_json_map::ConfigFormat::Jsonc,
185 )
186 }
187
188 fn plan_uninstall_mcp(
189 &self,
190 scope: &Scope,
191 name: &str,
192 owner_tag: &str,
193 ) -> Result<UninstallPlan, AgentConfigError> {
194 agent_planning::mcp_json_map_uninstall(
195 McpSurface::id(self),
196 scope,
197 name,
198 owner_tag,
199 Self::mcp_path(scope),
200 &["mcp"],
201 mcp_json_map::ConfigFormat::Jsonc,
202 )
203 }
204
205 fn install_mcp(
206 &self,
207 scope: &Scope,
208 spec: &McpSpec,
209 ) -> Result<InstallReport, AgentConfigError> {
210 spec.validate()?;
211 let cfg = Self::mcp_path(scope)?;
212 spec.validate_local_secret_policy(scope)?;
213 scope.ensure_contained(&cfg)?;
214 let ledger = ownership::mcp_ledger_for(&cfg);
215 mcp_json_map::install(
216 &cfg,
217 &ledger,
218 spec,
219 &["mcp"],
220 mcp_json_map::command_array_value,
221 mcp_json_map::ConfigFormat::Jsonc,
222 )
223 }
224
225 fn uninstall_mcp(
226 &self,
227 scope: &Scope,
228 name: &str,
229 owner_tag: &str,
230 ) -> Result<UninstallReport, AgentConfigError> {
231 McpSpec::validate_name(name)?;
232 HookSpec::validate_tag(owner_tag)?;
233 let cfg = Self::mcp_path(scope)?;
234 scope.ensure_contained(&cfg)?;
235 let ledger = ownership::mcp_ledger_for(&cfg);
236 mcp_json_map::uninstall(
237 &cfg,
238 &ledger,
239 name,
240 owner_tag,
241 "mcp server",
242 &["mcp"],
243 mcp_json_map::ConfigFormat::Jsonc,
244 )
245 }
246}
247
248impl SkillSurface for KiloCodeAgent {
249 fn id(&self) -> &'static str {
250 "kilocode"
251 }
252
253 fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
254 &[ScopeKind::Global, ScopeKind::Local]
255 }
256
257 fn skill_status(
258 &self,
259 scope: &Scope,
260 name: &str,
261 expected_owner: &str,
262 ) -> Result<StatusReport, AgentConfigError> {
263 SkillSpec::validate_name(name)?;
264 let root = Self::skills_root(scope)?;
265 let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
266 let recorded = ownership::owner_of(&ledger, name)?;
267 Ok(StatusReport::for_skill(
268 name,
269 dir,
270 manifest,
271 ledger,
272 expected_owner,
273 recorded,
274 ))
275 }
276
277 fn plan_install_skill(
278 &self,
279 scope: &Scope,
280 spec: &SkillSpec,
281 ) -> Result<InstallPlan, AgentConfigError> {
282 agent_planning::skill_install(
283 SkillSurface::id(self),
284 scope,
285 spec,
286 Self::skills_root(scope),
287 )
288 }
289
290 fn plan_uninstall_skill(
291 &self,
292 scope: &Scope,
293 name: &str,
294 owner_tag: &str,
295 ) -> Result<UninstallPlan, AgentConfigError> {
296 agent_planning::skill_uninstall(
297 SkillSurface::id(self),
298 scope,
299 name,
300 owner_tag,
301 Self::skills_root(scope),
302 )
303 }
304
305 fn install_skill(
306 &self,
307 scope: &Scope,
308 spec: &SkillSpec,
309 ) -> Result<InstallReport, AgentConfigError> {
310 let root = Self::skills_root(scope)?;
311 scope.ensure_contained(&root)?;
312 skills_dir::install(&root, spec)
313 }
314
315 fn uninstall_skill(
316 &self,
317 scope: &Scope,
318 name: &str,
319 owner_tag: &str,
320 ) -> Result<UninstallReport, AgentConfigError> {
321 let root = Self::skills_root(scope)?;
322 skills_dir::uninstall(&root, name, owner_tag)
323 }
324}
325
326impl KiloCodeAgent {
327 fn standalone_layout(
328 &self,
329 scope: &Scope,
330 ) -> Result<instructions_dir::StandaloneLayout, AgentConfigError> {
331 let root = self.require_local(scope)?;
332 Ok(instructions_dir::StandaloneLayout {
333 config_dir: root.join(".kilo"),
334 instruction_dir: root.join(".kilo/rules"),
335 })
336 }
337}
338
339impl InstructionSurface for KiloCodeAgent {
340 fn id(&self) -> &'static str {
341 "kilocode"
342 }
343
344 fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
345 &[ScopeKind::Local]
346 }
347
348 fn instruction_status(
349 &self,
350 scope: &Scope,
351 name: &str,
352 expected_owner: &str,
353 ) -> Result<StatusReport, AgentConfigError> {
354 instructions_dir::standalone_status(self.standalone_layout(scope)?, name, expected_owner)
355 }
356
357 fn plan_install_instruction(
358 &self,
359 scope: &Scope,
360 spec: &InstructionSpec,
361 ) -> Result<InstallPlan, AgentConfigError> {
362 instructions_dir::standalone_plan_install(
363 InstructionSurface::id(self),
364 scope,
365 self.standalone_layout(scope),
366 spec,
367 )
368 }
369
370 fn plan_uninstall_instruction(
371 &self,
372 scope: &Scope,
373 name: &str,
374 owner_tag: &str,
375 ) -> Result<UninstallPlan, AgentConfigError> {
376 instructions_dir::standalone_plan_uninstall(
377 InstructionSurface::id(self),
378 scope,
379 self.standalone_layout(scope),
380 name,
381 owner_tag,
382 )
383 }
384
385 fn install_instruction(
386 &self,
387 scope: &Scope,
388 spec: &InstructionSpec,
389 ) -> Result<InstallReport, AgentConfigError> {
390 instructions_dir::standalone_install(scope, self.standalone_layout(scope)?, spec)
391 }
392
393 fn uninstall_instruction(
394 &self,
395 scope: &Scope,
396 name: &str,
397 owner_tag: &str,
398 ) -> Result<UninstallReport, AgentConfigError> {
399 instructions_dir::standalone_uninstall(
400 scope,
401 self.standalone_layout(scope)?,
402 name,
403 owner_tag,
404 )
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use crate::spec::InstructionPlacement;
412 use serde_json::{json, Value};
413 use tempfile::tempdir;
414
415 fn rules_spec(tag: &str, body: &str) -> HookSpec {
416 HookSpec::builder(tag)
417 .command_program("noop", [] as [&str; 0])
418 .rules(body)
419 .build()
420 }
421
422 fn mcp_spec(name: &str, owner: &str) -> McpSpec {
423 McpSpec::builder(name)
424 .owner(owner)
425 .stdio("npx", ["-y", "@example/server"])
426 .env("FOO", "bar")
427 .build()
428 }
429
430 fn read_json(p: &Path) -> Value {
431 serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
432 }
433
434 #[test]
435 fn install_rules_writes_kilo_rules_path() {
436 let dir = tempdir().unwrap();
437 let agent = KiloCodeAgent::new();
438 let scope = Scope::Local(dir.path().to_path_buf());
439 agent.install(&scope, &rules_spec("alpha", "body")).unwrap();
440 assert!(dir.path().join(".kilo/rules/alpha.md").exists());
441 }
442
443 #[test]
444 fn install_mcp_writes_project_kilo_jsonc() {
445 let dir = tempdir().unwrap();
446 let agent = KiloCodeAgent::new();
447 let scope = Scope::Local(dir.path().to_path_buf());
448 agent
449 .install_mcp(&scope, &mcp_spec("github", "myapp"))
450 .unwrap();
451 let cfg = dir.path().join("kilo.jsonc");
452 let v = read_json(&cfg);
453 assert_eq!(v["mcp"]["github"]["type"], json!("local"));
454 assert_eq!(
455 v["mcp"]["github"]["command"],
456 json!(["npx", "-y", "@example/server"])
457 );
458 assert_eq!(v["mcp"]["github"]["environment"]["FOO"], json!("bar"));
459 }
460
461 #[test]
462 fn install_mcp_uses_existing_dot_kilo_config() {
463 let dir = tempdir().unwrap();
464 let dot = dir.path().join(".kilo/kilo.jsonc");
465 std::fs::create_dir_all(dot.parent().unwrap()).unwrap();
466 std::fs::write(&dot, "{\n // existing\n}\n").unwrap();
467 let agent = KiloCodeAgent::new();
468 let scope = Scope::Local(dir.path().to_path_buf());
469 agent
470 .install_mcp(&scope, &mcp_spec("github", "myapp"))
471 .unwrap();
472 assert!(dot.exists());
473 assert!(!dir.path().join("kilo.jsonc").exists());
474 }
475
476 #[test]
477 fn install_mcp_reads_jsonc_with_comments_and_trailing_commas() {
478 let dir = tempdir().unwrap();
479 let cfg = dir.path().join("kilo.jsonc");
480 std::fs::write(
481 &cfg,
482 r#"{
483 // user server
484 "mcp": {
485 "user": {
486 "type": "remote",
487 "url": "https://example.com/mcp",
488 },
489 },
490}
491"#,
492 )
493 .unwrap();
494 let agent = KiloCodeAgent::new();
495 let scope = Scope::Local(dir.path().to_path_buf());
496 agent
497 .install_mcp(&scope, &mcp_spec("github", "myapp"))
498 .unwrap();
499 let v = read_json(&cfg);
500 assert_eq!(v["mcp"]["user"]["url"], json!("https://example.com/mcp"));
501 assert_eq!(v["mcp"]["github"]["type"], json!("local"));
502 }
503
504 #[test]
505 fn uninstall_mcp_owner_mismatch_refused() {
506 let dir = tempdir().unwrap();
507 let agent = KiloCodeAgent::new();
508 let scope = Scope::Local(dir.path().to_path_buf());
509 agent
510 .install_mcp(&scope, &mcp_spec("github", "appA"))
511 .unwrap();
512 let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
513 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
514 }
515
516 fn instruction_spec(name: &str, owner: &str, body: &str) -> InstructionSpec {
517 InstructionSpec::builder(name)
518 .owner(owner)
519 .placement(InstructionPlacement::StandaloneFile)
520 .body(body)
521 .build()
522 }
523
524 #[test]
525 fn instruction_writes_to_rules_dir() {
526 let dir = tempdir().unwrap();
527 let agent = KiloCodeAgent::new();
528 let scope = Scope::Local(dir.path().to_path_buf());
529 agent
530 .install_instruction(&scope, &instruction_spec("MYAPP", "myapp", "# Use MyApp\n"))
531 .unwrap();
532 let instr = dir.path().join(".kilo/rules/MYAPP.md");
533 assert!(instr.exists());
534 assert!(std::fs::read_to_string(&instr)
535 .unwrap()
536 .contains("# Use MyApp"));
537 }
538
539 #[test]
540 fn instruction_uninstall_removes_file() {
541 let dir = tempdir().unwrap();
542 let agent = KiloCodeAgent::new();
543 let scope = Scope::Local(dir.path().to_path_buf());
544 agent
545 .install_instruction(&scope, &instruction_spec("MYAPP", "myapp", "# Use MyApp\n"))
546 .unwrap();
547 agent
548 .uninstall_instruction(&scope, "MYAPP", "myapp")
549 .unwrap();
550 assert!(!dir.path().join(".kilo/rules/MYAPP.md").exists());
551 }
552
553 #[test]
554 fn instruction_rejects_global_scope() {
555 let agent = KiloCodeAgent::new();
556 let err = agent
557 .install_instruction(
558 &Scope::Global,
559 &instruction_spec("MYAPP", "myapp", "body\n"),
560 )
561 .unwrap_err();
562 assert!(matches!(err, AgentConfigError::UnsupportedScope { .. }));
563 }
564}