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