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