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        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}