1use 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#[derive(Debug, Clone, Copy, Default)]
39pub struct CodeBuddyAgent {
40 _private: (),
41}
42
43impl CodeBuddyAgent {
44 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 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}