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 `.kilo/rules/<tag>.md`
6//!    (current Kilo standard; legacy `.kilocode/rules` still works upstream).
7//!
8//! 2. **MCP servers** — JSONC config at `~/.config/kilo/kilo.jsonc`
9//!    (Global) or project `kilo.jsonc` / `.kilo/kilo.jsonc` (Local), keyed by
10//!    server name under object-based `mcp`.
11
12use 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/// Kilo Code integration.
30#[derive(Debug, Clone, Copy, Default)]
31pub struct KiloCodeAgent {
32    _private: (),
33}
34
35impl KiloCodeAgent {
36    /// Construct an instance. Stateless.
37    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        // Surface typed UnsupportedScope before rules_dir falls back to PathResolution.
122        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}