Skip to main content

agent_config/agents/
codebuddy.rs

1//! Tencent CodeBuddy CLI integration.
2//!
3//! CodeBuddy mirrors the Claude Code envelope: a `settings.json` with the
4//! same `hooks.<event>` array structure, plus a `CLAUDE.md` memory file.
5//!
6//! Surfaces:
7//!
8//! 1. **Hooks**: `settings.json` JSON envelope (Claude shape). CodeBuddy
9//!    documents nine events (`PreToolUse`, `PostToolUse`, `Notification`,
10//!    `UserPromptSubmit`, `Stop`, `SubagentStop`, `PreCompact`,
11//!    `SessionStart`, `SessionEnd`).
12//! 2. **Prompt rules**: fenced HTML-comment block in `CLAUDE.md`.
13//! 3. **Skills**: directory-scoped `SKILL.md` folders.
14//!
15//! MCP is not part of CodeBuddy's documented file-config surface.
16
17use std::path::{Path, PathBuf};
18
19use serde_json::json;
20
21use crate::error::AgentConfigError;
22use crate::integration::{
23    InstallReport, InstructionSurface, Integration, SkillSurface, UninstallReport,
24};
25use crate::paths;
26use crate::plan::{has_refusal, InstallPlan, PlanTarget, UninstallPlan};
27use crate::scope::{Scope, ScopeKind};
28use crate::spec::{Event, HookSpec, InstructionSpec, Matcher, SkillSpec};
29use crate::status::StatusReport;
30use crate::util::{
31    file_lock, fs_atomic, instructions_dir, json_patch, md_block, ownership, planning, safe_fs,
32    skills_dir,
33};
34
35use crate::agents::planning as agent_planning;
36
37/// Tencent CodeBuddy CLI installer.
38#[derive(Debug, Clone, Copy, Default)]
39pub struct CodeBuddyAgent {
40    _private: (),
41}
42
43impl CodeBuddyAgent {
44    /// Construct an instance. Stateless.
45    pub const fn new() -> Self {
46        Self { _private: () }
47    }
48
49    fn codebuddy_home_from_home(home: &Path) -> PathBuf {
50        home.join(".codebuddy")
51    }
52
53    fn settings_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
54        Ok(match scope {
55            Scope::Global => {
56                Self::codebuddy_home_from_home(&paths::home_dir()?).join("settings.json")
57            }
58            Scope::Local(p) => p.join(".codebuddy").join("settings.json"),
59        })
60    }
61
62    fn memory_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
63        Ok(match scope {
64            Scope::Global => Self::codebuddy_home_from_home(&paths::home_dir()?).join("CLAUDE.md"),
65            Scope::Local(p) => p.join("CLAUDE.md"),
66        })
67    }
68
69    fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
70        Ok(match scope {
71            Scope::Global => Self::codebuddy_home_from_home(&paths::home_dir()?).join("skills"),
72            Scope::Local(p) => p.join(".codebuddy").join("skills"),
73        })
74    }
75
76    /// Directory holding the instruction ownership ledger.
77    /// Global: `~/.codebuddy/`. Local: `<root>/.codebuddy/`.
78    fn instruction_config_dir(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
79        Ok(match scope {
80            Scope::Global => Self::codebuddy_home_from_home(&paths::home_dir()?),
81            Scope::Local(p) => p.join(".codebuddy"),
82        })
83    }
84}
85
86impl Integration for CodeBuddyAgent {
87    fn id(&self) -> &'static str {
88        "codebuddy"
89    }
90
91    fn display_name(&self) -> &'static str {
92        "CodeBuddy CLI"
93    }
94
95    fn supported_scopes(&self) -> &'static [ScopeKind] {
96        &[ScopeKind::Global, ScopeKind::Local]
97    }
98
99    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
100        HookSpec::validate_tag(tag)?;
101        let settings = Self::settings_path(scope)?;
102        let presence = json_patch::tagged_hook_presence(&settings, &["hooks"], tag)?;
103        Ok(StatusReport::for_tagged_hook(tag, settings, presence))
104    }
105
106    fn plan_install(
107        &self,
108        scope: &Scope,
109        spec: &HookSpec,
110    ) -> Result<InstallPlan, AgentConfigError> {
111        HookSpec::validate_tag(&spec.tag)?;
112        let target = PlanTarget::Hook {
113            integration_id: Integration::id(self),
114            scope: scope.clone(),
115            tag: spec.tag.clone(),
116        };
117        let settings = Self::settings_path(scope)?;
118        let mut changes = Vec::new();
119
120        let event_key = event_to_string(&spec.event);
121        let matcher_str = matcher_to_codebuddy(&spec.matcher);
122        let entry = json!({
123            "matcher": matcher_str,
124            "hooks": [{ "type": "command", "command": spec.command.render_shell() }],
125        });
126        planning::plan_tagged_json_upsert(
127            &mut changes,
128            &settings,
129            &["hooks", event_key.as_str()],
130            &spec.tag,
131            entry,
132            |_| {},
133        )?;
134        if has_refusal(&changes) {
135            return Ok(InstallPlan::from_changes(target, changes));
136        }
137
138        if let Some(rules) = &spec.rules {
139            let memory = Self::memory_path(scope)?;
140            planning::plan_markdown_upsert(&mut changes, &memory, &spec.tag, &rules.content)?;
141        }
142
143        Ok(InstallPlan::from_changes(target, changes))
144    }
145
146    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
147        HookSpec::validate_tag(tag)?;
148        let target = PlanTarget::Hook {
149            integration_id: Integration::id(self),
150            scope: scope.clone(),
151            tag: tag.to_string(),
152        };
153        let mut changes = Vec::new();
154        let settings = Self::settings_path(scope)?;
155        planning::plan_tagged_json_remove_under(
156            &mut changes,
157            &settings,
158            &["hooks"],
159            tag,
160            planning::json_object_empty,
161            true,
162        )?;
163        if has_refusal(&changes) {
164            return Ok(UninstallPlan::from_changes(target, changes));
165        }
166
167        let memory = Self::memory_path(scope)?;
168        planning::plan_markdown_remove(&mut changes, &memory, tag)?;
169
170        Ok(UninstallPlan::from_changes(target, changes))
171    }
172
173    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
174        HookSpec::validate_tag(&spec.tag)?;
175        let mut report = InstallReport::default();
176
177        let settings = Self::settings_path(scope)?;
178        scope.ensure_contained(&settings)?;
179        file_lock::with_lock(&settings, || {
180            let mut root = json_patch::read_or_empty(&settings)?;
181
182            let event_key = event_to_string(&spec.event);
183            let matcher_str = matcher_to_codebuddy(&spec.matcher);
184
185            let entry = json!({
186                "matcher": matcher_str,
187                "hooks": [{ "type": "command", "command": spec.command.render_shell() }],
188            });
189
190            let changed = json_patch::upsert_tagged_array_entry(
191                &mut root,
192                &["hooks", &event_key],
193                &spec.tag,
194                entry,
195            )?;
196
197            if changed {
198                let bytes = json_patch::to_pretty(&root);
199                let outcome = safe_fs::write(scope, &settings, &bytes, true)?;
200                if outcome.existed {
201                    report.patched.push(outcome.path.clone());
202                } else {
203                    report.created.push(outcome.path.clone());
204                }
205                if let Some(b) = outcome.backup {
206                    report.backed_up.push(b);
207                }
208            } else {
209                report.already_installed = true;
210            }
211            Ok::<(), AgentConfigError>(())
212        })?;
213
214        if let Some(rules) = &spec.rules {
215            let memory = Self::memory_path(scope)?;
216            scope.ensure_contained(&memory)?;
217            file_lock::with_lock(&memory, || {
218                let host = fs_atomic::read_to_string_or_empty(&memory)?;
219                let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
220                let outcome = safe_fs::write(scope, &memory, new_host.as_bytes(), true)?;
221                if !outcome.no_change {
222                    if outcome.existed {
223                        report.patched.push(outcome.path.clone());
224                    } else {
225                        report.created.push(outcome.path.clone());
226                    }
227                    report.already_installed = false;
228                }
229                if let Some(b) = outcome.backup {
230                    report.backed_up.push(b);
231                }
232                Ok::<(), AgentConfigError>(())
233            })?;
234        }
235
236        Ok(report)
237    }
238
239    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
240        HookSpec::validate_tag(tag)?;
241        let mut report = UninstallReport::default();
242
243        let settings = Self::settings_path(scope)?;
244        scope.ensure_contained(&settings)?;
245        if settings.exists() {
246            file_lock::with_lock(&settings, || {
247                let mut root = json_patch::read_or_empty(&settings)?;
248                let changed =
249                    json_patch::remove_tagged_array_entries_under(&mut root, &["hooks"], tag)?;
250                if changed {
251                    let is_now_empty = root.as_object().map(|o| o.is_empty()).unwrap_or(true);
252                    let bytes = json_patch::to_pretty(&root);
253                    if is_now_empty && safe_fs::restore_backup_if_matches(scope, &settings, &bytes)?
254                    {
255                        report.restored.push(settings.clone());
256                    } else if is_now_empty {
257                        safe_fs::remove_file(scope, &settings)?;
258                        report.removed.push(settings.clone());
259                    } else {
260                        safe_fs::write(scope, &settings, &bytes, false)?;
261                        report.patched.push(settings.clone());
262                    }
263                }
264                Ok::<(), AgentConfigError>(())
265            })?;
266        }
267
268        let memory = Self::memory_path(scope)?;
269        scope.ensure_contained(&memory)?;
270        file_lock::with_lock(&memory, || {
271            let host = fs_atomic::read_to_string_or_empty(&memory)?;
272            let (stripped, removed) = md_block::remove(&host, tag);
273            if removed {
274                if stripped.trim().is_empty() {
275                    if safe_fs::restore_backup_if_matches(scope, &memory, stripped.as_bytes())? {
276                        report.restored.push(memory.clone());
277                    } else {
278                        safe_fs::remove_file(scope, &memory)?;
279                        report.removed.push(memory.clone());
280                    }
281                } else {
282                    safe_fs::write(scope, &memory, stripped.as_bytes(), false)?;
283                    report.patched.push(memory.clone());
284                }
285            }
286            Ok::<(), AgentConfigError>(())
287        })?;
288
289        if report.removed.is_empty() && report.patched.is_empty() && report.restored.is_empty() {
290            report.not_installed = true;
291        }
292        Ok(report)
293    }
294}
295
296impl SkillSurface for CodeBuddyAgent {
297    fn id(&self) -> &'static str {
298        "codebuddy"
299    }
300
301    fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
302        &[ScopeKind::Global, ScopeKind::Local]
303    }
304
305    fn skill_status(
306        &self,
307        scope: &Scope,
308        name: &str,
309        expected_owner: &str,
310    ) -> Result<StatusReport, AgentConfigError> {
311        SkillSpec::validate_name(name)?;
312        let root = Self::skills_root(scope)?;
313        let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
314        let recorded = ownership::owner_of(&ledger, name)?;
315        Ok(StatusReport::for_skill(
316            name,
317            dir,
318            manifest,
319            ledger,
320            expected_owner,
321            recorded,
322        ))
323    }
324
325    fn plan_install_skill(
326        &self,
327        scope: &Scope,
328        spec: &SkillSpec,
329    ) -> Result<InstallPlan, AgentConfigError> {
330        agent_planning::skill_install(
331            SkillSurface::id(self),
332            scope,
333            spec,
334            Self::skills_root(scope),
335        )
336    }
337
338    fn plan_uninstall_skill(
339        &self,
340        scope: &Scope,
341        name: &str,
342        owner_tag: &str,
343    ) -> Result<UninstallPlan, AgentConfigError> {
344        agent_planning::skill_uninstall(
345            SkillSurface::id(self),
346            scope,
347            name,
348            owner_tag,
349            Self::skills_root(scope),
350        )
351    }
352
353    fn install_skill(
354        &self,
355        scope: &Scope,
356        spec: &SkillSpec,
357    ) -> Result<InstallReport, AgentConfigError> {
358        let root = Self::skills_root(scope)?;
359        scope.ensure_contained(&root)?;
360        skills_dir::install(&root, spec)
361    }
362
363    fn uninstall_skill(
364        &self,
365        scope: &Scope,
366        name: &str,
367        owner_tag: &str,
368    ) -> Result<UninstallReport, AgentConfigError> {
369        let root = Self::skills_root(scope)?;
370        scope.ensure_contained(&root)?;
371        skills_dir::uninstall(&root, name, owner_tag)
372    }
373}
374
375impl CodeBuddyAgent {
376    fn inline_layout(
377        &self,
378        scope: &Scope,
379    ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
380        Ok(instructions_dir::InlineLayout {
381            config_dir: Self::instruction_config_dir(scope)?,
382            host_file: Self::memory_path(scope)?,
383        })
384    }
385}
386
387impl InstructionSurface for CodeBuddyAgent {
388    fn id(&self) -> &'static str {
389        "codebuddy"
390    }
391
392    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
393        &[ScopeKind::Global, ScopeKind::Local]
394    }
395
396    fn instruction_status(
397        &self,
398        scope: &Scope,
399        name: &str,
400        expected_owner: &str,
401    ) -> Result<StatusReport, AgentConfigError> {
402        instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
403    }
404
405    fn plan_install_instruction(
406        &self,
407        scope: &Scope,
408        spec: &InstructionSpec,
409    ) -> Result<InstallPlan, AgentConfigError> {
410        instructions_dir::inline_plan_install(
411            InstructionSurface::id(self),
412            scope,
413            self.inline_layout(scope),
414            spec,
415        )
416    }
417
418    fn plan_uninstall_instruction(
419        &self,
420        scope: &Scope,
421        name: &str,
422        owner_tag: &str,
423    ) -> Result<UninstallPlan, AgentConfigError> {
424        instructions_dir::inline_plan_uninstall(
425            InstructionSurface::id(self),
426            scope,
427            self.inline_layout(scope),
428            name,
429            owner_tag,
430        )
431    }
432
433    fn install_instruction(
434        &self,
435        scope: &Scope,
436        spec: &InstructionSpec,
437    ) -> Result<InstallReport, AgentConfigError> {
438        instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
439    }
440
441    fn uninstall_instruction(
442        &self,
443        scope: &Scope,
444        name: &str,
445        owner_tag: &str,
446    ) -> Result<UninstallReport, AgentConfigError> {
447        instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
448    }
449}
450
451fn matcher_to_codebuddy(m: &Matcher) -> String {
452    match m {
453        Matcher::All => "*".to_string(),
454        Matcher::Bash => "Bash".to_string(),
455        Matcher::Exact(s) => s.clone(),
456        Matcher::AnyOf(names) => names.join("|"),
457        Matcher::Regex(s) => s.clone(),
458    }
459}
460
461fn event_to_string(e: &Event) -> String {
462    match e {
463        Event::PreToolUse => "PreToolUse".into(),
464        Event::PostToolUse => "PostToolUse".into(),
465        Event::Custom(s) => s.clone(),
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use serde_json::Value;
473    use tempfile::tempdir;
474
475    fn local_spec(tag: &str) -> HookSpec {
476        HookSpec::builder(tag)
477            .command_program("myapp", ["hook"])
478            .matcher(Matcher::Bash)
479            .event(Event::PreToolUse)
480            .build()
481    }
482
483    fn skill(name: &str, owner: &str) -> SkillSpec {
484        SkillSpec::builder(name)
485            .owner(owner)
486            .description("Test CodeBuddy skill.")
487            .body("## Goal\nDo it.\n")
488            .build()
489    }
490
491    fn read_json(p: &Path) -> Value {
492        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
493    }
494
495    #[test]
496    fn install_writes_settings_with_claude_shape() {
497        let dir = tempdir().unwrap();
498        let agent = CodeBuddyAgent::new();
499        let scope = Scope::Local(dir.path().to_path_buf());
500        agent.install(&scope, &local_spec("alpha")).unwrap();
501
502        let v = read_json(&dir.path().join(".codebuddy/settings.json"));
503        assert_eq!(v["hooks"]["PreToolUse"][0]["matcher"], json!("Bash"));
504        assert_eq!(
505            v["hooks"]["PreToolUse"][0]["_agent_config_tag"],
506            json!("alpha")
507        );
508    }
509
510    #[test]
511    fn install_idempotent() {
512        let dir = tempdir().unwrap();
513        let agent = CodeBuddyAgent::new();
514        let scope = Scope::Local(dir.path().to_path_buf());
515        let spec = local_spec("alpha");
516        agent.install(&scope, &spec).unwrap();
517        let r2 = agent.install(&scope, &spec).unwrap();
518        assert!(r2.already_installed);
519    }
520
521    #[test]
522    fn install_uninstall_round_trip() {
523        let dir = tempdir().unwrap();
524        let agent = CodeBuddyAgent::new();
525        let scope = Scope::Local(dir.path().to_path_buf());
526        agent.install(&scope, &local_spec("alpha")).unwrap();
527        agent.uninstall(&scope, "alpha").unwrap();
528        assert!(!dir.path().join(".codebuddy/settings.json").exists());
529    }
530
531    #[test]
532    fn rules_block_writes_to_claude_md() {
533        let dir = tempdir().unwrap();
534        let agent = CodeBuddyAgent::new();
535        let scope = Scope::Local(dir.path().to_path_buf());
536        let spec = HookSpec::builder("alpha")
537            .command_program("noop", [] as [&str; 0])
538            .rules("Use CodeBuddy rules.")
539            .build();
540        agent.install(&scope, &spec).unwrap();
541        let md = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
542        assert!(md.contains("Use CodeBuddy rules."));
543    }
544
545    #[test]
546    fn install_skill_writes_skills_dir() {
547        let dir = tempdir().unwrap();
548        let agent = CodeBuddyAgent::new();
549        let scope = Scope::Local(dir.path().to_path_buf());
550        agent
551            .install_skill(&scope, &skill("alpha-skill", "myapp"))
552            .unwrap();
553        assert!(dir
554            .path()
555            .join(".codebuddy/skills/alpha-skill/SKILL.md")
556            .exists());
557    }
558
559    #[test]
560    fn custom_event_round_trip() {
561        let dir = tempdir().unwrap();
562        let agent = CodeBuddyAgent::new();
563        let scope = Scope::Local(dir.path().to_path_buf());
564        let spec = HookSpec::builder("alpha")
565            .command_program("noop", [] as [&str; 0])
566            .event(Event::Custom("SessionStart".into()))
567            .build();
568        agent.install(&scope, &spec).unwrap();
569        let v = read_json(&dir.path().join(".codebuddy/settings.json"));
570        assert!(v["hooks"]["SessionStart"].is_array());
571    }
572}