Skip to main content

agent_config/agents/
kilocode.rs

1//! Kilo Code integration.
2//!
3//! Two surfaces:
4//!
5//! 1. **Rules** — project-local markdown files at `.kilocode/rules/<tag>.md`.
6//!
7//! 2. **MCP servers** — JSONC config at `~/.config/kilo/kilo.jsonc`
8//!    (Global) or project `kilo.jsonc` / `.kilo/kilo.jsonc` (Local), keyed by
9//!    server name under object-based `mcp`.
10
11use 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/// Kilo Code integration.
29#[derive(Debug, Clone, Copy, Default)]
30pub struct KiloCodeAgent {
31    _private: (),
32}
33
34impl KiloCodeAgent {
35    /// Construct an instance. Stateless.
36    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        // Surface typed UnsupportedScope before rules_dir falls back to PathResolution.
121        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}