1use std::path::PathBuf;
26
27use serde_json::json;
28
29use crate::agents::planning as agent_planning;
30use crate::error::AgentConfigError;
31use crate::integration::{InstallReport, Integration, McpSurface, SkillSurface, UninstallReport};
32use crate::paths;
33use crate::plan::{has_refusal, InstallPlan, PlanTarget, UninstallPlan};
34use crate::scope::{Scope, ScopeKind};
35use crate::spec::{Event, HookCommand, HookSpec, Matcher, McpSpec, SkillSpec};
36use crate::status::StatusReport;
37use crate::util::{
38 file_lock, fs_atomic, json_patch, mcp_json_object, md_block, ownership, planning, safe_fs,
39 skills_dir,
40};
41
42mod instructions;
43
44#[derive(Debug, Clone, Copy, Default)]
46pub struct ClaudeAgent {
47 _private: (),
48}
49
50impl ClaudeAgent {
51 pub const fn new() -> Self {
53 Self { _private: () }
54 }
55
56 fn settings_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
57 Ok(match scope {
58 Scope::Global => paths::claude_home()?.join("settings.json"),
59 Scope::Local(p) => p.join(".claude").join("settings.json"),
60 })
61 }
62
63 pub(super) fn memory_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
64 Ok(match scope {
65 Scope::Global => paths::claude_home()?.join("CLAUDE.md"),
66 Scope::Local(p) => p.join("CLAUDE.md"),
67 })
68 }
69
70 fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
77 Ok(match scope {
78 Scope::Global => paths::claude_mcp_user_file()?,
79 Scope::Local(p) => p.join(".mcp.json"),
80 })
81 }
82
83 fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
85 Ok(match scope {
86 Scope::Global => paths::claude_home()?.join("skills"),
87 Scope::Local(p) => p.join(".claude").join("skills"),
88 })
89 }
90}
91
92impl Integration for ClaudeAgent {
93 fn id(&self) -> &'static str {
94 "claude"
95 }
96
97 fn display_name(&self) -> &'static str {
98 "Claude Code"
99 }
100
101 fn supported_scopes(&self) -> &'static [ScopeKind] {
102 &[ScopeKind::Global, ScopeKind::Local]
103 }
104
105 fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
106 HookSpec::validate_tag(tag)?;
107 let settings = Self::settings_path(scope)?;
108 let presence = json_patch::tagged_hook_presence(&settings, &["hooks"], tag)?;
109 Ok(StatusReport::for_tagged_hook(tag, settings, presence))
110 }
111
112 fn plan_install(
113 &self,
114 scope: &Scope,
115 spec: &HookSpec,
116 ) -> Result<InstallPlan, AgentConfigError> {
117 HookSpec::validate_tag(&spec.tag)?;
118 let target = PlanTarget::Hook {
119 integration_id: Integration::id(self),
120 scope: scope.clone(),
121 tag: spec.tag.clone(),
122 };
123 let settings = Self::settings_path(scope)?;
124 let mut changes = Vec::new();
125
126 let event_key = event_to_string(&spec.event);
127 let matcher_str = matcher_to_claude(&spec.matcher);
128 let entry = json!({
129 "matcher": matcher_str,
130 "hooks": [command_hook_to_claude(&spec.command)],
131 });
132 planning::plan_tagged_json_upsert(
133 &mut changes,
134 &settings,
135 &["hooks", event_key.as_str()],
136 &spec.tag,
137 entry,
138 |_| {},
139 )?;
140 if has_refusal(&changes) {
141 return Ok(InstallPlan::from_changes(target, changes));
142 }
143
144 if let Some(rules) = &spec.rules {
145 let memory = Self::memory_path(scope)?;
146 planning::plan_markdown_upsert(&mut changes, &memory, &spec.tag, &rules.content)?;
147 }
148
149 Ok(InstallPlan::from_changes(target, changes))
150 }
151
152 fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
153 HookSpec::validate_tag(tag)?;
154 let target = PlanTarget::Hook {
155 integration_id: Integration::id(self),
156 scope: scope.clone(),
157 tag: tag.to_string(),
158 };
159 let mut changes = Vec::new();
160 let settings = Self::settings_path(scope)?;
161 planning::plan_tagged_json_remove_under(
162 &mut changes,
163 &settings,
164 &["hooks"],
165 tag,
166 planning::json_object_empty,
167 true,
168 )?;
169 if has_refusal(&changes) {
170 return Ok(UninstallPlan::from_changes(target, changes));
171 }
172
173 let memory = Self::memory_path(scope)?;
174 planning::plan_markdown_remove(&mut changes, &memory, tag)?;
175
176 Ok(UninstallPlan::from_changes(target, changes))
177 }
178
179 fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
180 HookSpec::validate_tag(&spec.tag)?;
181 let mut report = InstallReport::default();
182
183 let settings = Self::settings_path(scope)?;
184 scope.ensure_contained(&settings)?;
185 {
186 let _settings_lock = file_lock::FileLock::acquire(&settings)?;
187 let mut root = json_patch::read_or_empty(&settings)?;
188
189 let event_key = event_to_string(&spec.event);
190 let matcher_str = matcher_to_claude(&spec.matcher);
191
192 let entry = json!({
193 "matcher": matcher_str,
194 "hooks": [command_hook_to_claude(&spec.command)],
195 });
196
197 let changed = json_patch::upsert_tagged_array_entry(
198 &mut root,
199 &["hooks", &event_key],
200 &spec.tag,
201 entry,
202 )?;
203
204 if changed {
205 let bytes = json_patch::to_pretty(&root);
206 let outcome = safe_fs::write(scope, &settings, &bytes, true)?;
207 if outcome.existed {
208 report.patched.push(outcome.path.clone());
209 } else {
210 report.created.push(outcome.path.clone());
211 }
212 if let Some(b) = outcome.backup {
213 report.backed_up.push(b);
214 }
215 } else {
216 report.already_installed = true;
217 }
218 }
219
220 if let Some(rules) = &spec.rules {
221 let memory = Self::memory_path(scope)?;
222 scope.ensure_contained(&memory)?;
223 let _memory_lock = file_lock::FileLock::acquire(&memory)?;
224 let host = fs_atomic::read_to_string_or_empty(&memory)?;
225 let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
226 let outcome = safe_fs::write(scope, &memory, new_host.as_bytes(), true)?;
227 if !outcome.no_change {
228 if outcome.existed {
229 report.patched.push(outcome.path.clone());
230 } else {
231 report.created.push(outcome.path.clone());
232 }
233 report.already_installed = false;
234 }
235 if let Some(b) = outcome.backup {
236 report.backed_up.push(b);
237 }
238 }
239
240 Ok(report)
241 }
242
243 fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
244 HookSpec::validate_tag(tag)?;
245 let mut report = UninstallReport::default();
246
247 let settings = Self::settings_path(scope)?;
248 scope.ensure_contained(&settings)?;
249 if settings.exists() {
250 let _settings_lock = file_lock::FileLock::acquire(&settings)?;
251 let mut root = json_patch::read_or_empty(&settings)?;
252 let changed =
253 json_patch::remove_tagged_array_entries_under(&mut root, &["hooks"], tag)?;
254 if changed {
255 let is_now_empty = root.as_object().map(|o| o.is_empty()).unwrap_or(true);
256 let bytes = json_patch::to_pretty(&root);
257 if is_now_empty && safe_fs::restore_backup_if_matches(scope, &settings, &bytes)? {
258 report.restored.push(settings.clone());
259 } else if is_now_empty {
260 safe_fs::remove_file(scope, &settings)?;
261 report.removed.push(settings.clone());
262 } else {
263 safe_fs::write(scope, &settings, &bytes, false)?;
264 report.patched.push(settings.clone());
265 }
266 }
267 }
268
269 let memory = Self::memory_path(scope)?;
270 scope.ensure_contained(&memory)?;
271 let _memory_lock = file_lock::FileLock::acquire(&memory)?;
272 let host = fs_atomic::read_to_string_or_empty(&memory)?;
273 let (stripped, removed) = md_block::remove(&host, tag);
274 if removed {
275 if stripped.trim().is_empty() {
276 if safe_fs::restore_backup_if_matches(scope, &memory, stripped.as_bytes())? {
277 report.restored.push(memory.clone());
278 } else {
279 safe_fs::remove_file(scope, &memory)?;
280 report.removed.push(memory.clone());
281 }
282 } else {
283 safe_fs::write(scope, &memory, stripped.as_bytes(), false)?;
284 report.patched.push(memory.clone());
285 }
286 }
287
288 if report.removed.is_empty() && report.patched.is_empty() && report.restored.is_empty() {
289 report.not_installed = true;
290 }
291 Ok(report)
292 }
293}
294
295impl McpSurface for ClaudeAgent {
296 fn id(&self) -> &'static str {
297 "claude"
298 }
299
300 fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
301 &[ScopeKind::Global, ScopeKind::Local]
302 }
303
304 fn mcp_status(
305 &self,
306 scope: &Scope,
307 name: &str,
308 expected_owner: &str,
309 ) -> Result<StatusReport, AgentConfigError> {
310 McpSpec::validate_name(name)?;
311 let cfg = Self::mcp_path(scope)?;
312 let ledger = ownership::mcp_ledger_for(&cfg);
313 let presence = mcp_json_object::config_presence(&cfg, name)?;
314 let recorded = ownership::owner_of(&ledger, name)?;
315 Ok(StatusReport::for_mcp(
316 name,
317 cfg,
318 ledger,
319 presence,
320 expected_owner,
321 recorded,
322 ))
323 }
324
325 fn plan_install_mcp(
326 &self,
327 scope: &Scope,
328 spec: &McpSpec,
329 ) -> Result<InstallPlan, AgentConfigError> {
330 spec.validate()?;
331 let target = PlanTarget::Mcp {
332 integration_id: McpSurface::id(self),
333 scope: scope.clone(),
334 name: spec.name.clone(),
335 owner: spec.owner_tag.clone(),
336 };
337 let cfg = Self::mcp_path(scope)?;
338 if let Some(plan) = agent_planning::mcp_local_inline_secret_refusal(
339 target.clone(),
340 scope,
341 spec,
342 Some(cfg.clone()),
343 ) {
344 return Ok(plan);
345 }
346 let ledger = ownership::mcp_ledger_for(&cfg);
347 let changes = mcp_json_object::plan_install(&cfg, &ledger, spec)?;
348 Ok(agent_planning::mcp_install_plan_from_changes(
349 target,
350 changes,
351 scope,
352 spec,
353 Some(cfg),
354 ))
355 }
356
357 fn plan_uninstall_mcp(
358 &self,
359 scope: &Scope,
360 name: &str,
361 owner_tag: &str,
362 ) -> Result<UninstallPlan, AgentConfigError> {
363 McpSpec::validate_name(name)?;
364 HookSpec::validate_tag(owner_tag)?;
365 let target = PlanTarget::Mcp {
366 integration_id: McpSurface::id(self),
367 scope: scope.clone(),
368 name: name.to_string(),
369 owner: owner_tag.to_string(),
370 };
371 let cfg = Self::mcp_path(scope)?;
372 let ledger = ownership::mcp_ledger_for(&cfg);
373 let changes =
374 mcp_json_object::plan_uninstall(&cfg, &ledger, name, owner_tag, "mcp server")?;
375 Ok(UninstallPlan::from_changes(target, changes))
376 }
377
378 fn install_mcp(
379 &self,
380 scope: &Scope,
381 spec: &McpSpec,
382 ) -> Result<InstallReport, AgentConfigError> {
383 spec.validate()?;
384 let cfg = Self::mcp_path(scope)?;
385 spec.validate_local_secret_policy(scope)?;
386 scope.ensure_contained(&cfg)?;
387 let ledger = ownership::mcp_ledger_for(&cfg);
388 mcp_json_object::install(&cfg, &ledger, spec)
389 }
390
391 fn uninstall_mcp(
392 &self,
393 scope: &Scope,
394 name: &str,
395 owner_tag: &str,
396 ) -> Result<UninstallReport, AgentConfigError> {
397 McpSpec::validate_name(name)?;
398 HookSpec::validate_tag(owner_tag)?;
399 let cfg = Self::mcp_path(scope)?;
400 scope.ensure_contained(&cfg)?;
401 let ledger = ownership::mcp_ledger_for(&cfg);
402 mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
403 }
404}
405
406impl SkillSurface for ClaudeAgent {
407 fn id(&self) -> &'static str {
408 "claude"
409 }
410
411 fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
412 &[ScopeKind::Global, ScopeKind::Local]
413 }
414
415 fn skill_status(
416 &self,
417 scope: &Scope,
418 name: &str,
419 expected_owner: &str,
420 ) -> Result<StatusReport, AgentConfigError> {
421 SkillSpec::validate_name(name)?;
422 let root = Self::skills_root(scope)?;
423 let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
424 let recorded = ownership::owner_of(&ledger, name)?;
425 Ok(StatusReport::for_skill(
426 name,
427 dir,
428 manifest,
429 ledger,
430 expected_owner,
431 recorded,
432 ))
433 }
434
435 fn plan_install_skill(
436 &self,
437 scope: &Scope,
438 spec: &SkillSpec,
439 ) -> Result<InstallPlan, AgentConfigError> {
440 spec.validate()?;
441 let target = PlanTarget::Skill {
442 integration_id: SkillSurface::id(self),
443 scope: scope.clone(),
444 name: spec.name.clone(),
445 owner: spec.owner_tag.clone(),
446 };
447 let root = Self::skills_root(scope)?;
448 let changes = skills_dir::plan_install(&root, spec)?;
449 Ok(InstallPlan::from_changes(target, changes))
450 }
451
452 fn plan_uninstall_skill(
453 &self,
454 scope: &Scope,
455 name: &str,
456 owner_tag: &str,
457 ) -> Result<UninstallPlan, AgentConfigError> {
458 SkillSpec::validate_name(name)?;
459 HookSpec::validate_tag(owner_tag)?;
460 let target = PlanTarget::Skill {
461 integration_id: SkillSurface::id(self),
462 scope: scope.clone(),
463 name: name.to_string(),
464 owner: owner_tag.to_string(),
465 };
466 let root = Self::skills_root(scope)?;
467 let changes = skills_dir::plan_uninstall(&root, name, owner_tag)?;
468 Ok(UninstallPlan::from_changes(target, changes))
469 }
470
471 fn install_skill(
472 &self,
473 scope: &Scope,
474 spec: &SkillSpec,
475 ) -> Result<InstallReport, AgentConfigError> {
476 spec.validate()?;
477 let root = Self::skills_root(scope)?;
478 scope.ensure_contained(&root)?;
479 skills_dir::install(&root, spec)
480 }
481
482 fn uninstall_skill(
483 &self,
484 scope: &Scope,
485 name: &str,
486 owner_tag: &str,
487 ) -> Result<UninstallReport, AgentConfigError> {
488 SkillSpec::validate_name(name)?;
489 HookSpec::validate_tag(owner_tag)?;
490 let root = Self::skills_root(scope)?;
491 scope.ensure_contained(&root)?;
492 skills_dir::uninstall(&root, name, owner_tag)
493 }
494}
495
496fn matcher_to_claude(m: &Matcher) -> String {
500 match m {
501 Matcher::All => "*".to_string(),
502 Matcher::Bash => "Bash".to_string(),
503 Matcher::Exact(s) => s.clone(),
504 Matcher::AnyOf(names) => names.join("|"),
505 Matcher::Regex(s) => s.clone(),
506 }
507}
508
509fn event_to_string(e: &Event) -> String {
510 match e {
511 Event::PreToolUse => "PreToolUse".into(),
512 Event::PostToolUse => "PostToolUse".into(),
513 Event::Custom(s) => s.clone(),
514 other => other.as_str().into(),
515 }
516}
517
518fn command_hook_to_claude(command: &HookCommand) -> serde_json::Value {
519 match command {
520 HookCommand::Program { program, args } => {
521 json!({ "type": "command", "command": program, "args": args })
522 }
523 HookCommand::ShellUnchecked { command } => {
524 json!({ "type": "command", "command": command })
525 }
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532 use serde_json::{json, Value};
533 use tempfile::tempdir;
534
535 fn local_spec(tag: &str) -> HookSpec {
536 HookSpec::builder(tag)
537 .command_program("myapp", ["hook"])
538 .matcher(Matcher::Bash)
539 .event(Event::PreToolUse)
540 .build()
541 }
542
543 fn read_json(p: &std::path::Path) -> Value {
544 serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
545 }
546
547 #[test]
548 fn local_install_writes_settings_json_with_correct_shape() {
549 let dir = tempdir().unwrap();
550 let agent = ClaudeAgent::new();
551 let scope = Scope::Local(dir.path().to_path_buf());
552 agent.install(&scope, &local_spec("alpha")).unwrap();
553
554 let p = dir.path().join(".claude/settings.json");
555 let v = read_json(&p);
556 assert_eq!(v["hooks"]["PreToolUse"][0]["matcher"], json!("Bash"));
557 assert_eq!(
558 v["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
559 json!("myapp")
560 );
561 assert_eq!(
562 v["hooks"]["PreToolUse"][0]["hooks"][0]["args"],
563 json!(["hook"])
564 );
565 assert_eq!(
566 v["hooks"]["PreToolUse"][0]["hooks"][0]["type"],
567 json!("command")
568 );
569 assert_eq!(
570 v["hooks"]["PreToolUse"][0]["_agent_config_tag"],
571 json!("alpha")
572 );
573 }
574
575 #[test]
576 fn program_hook_with_empty_args_renders_exec_form() {
577 let dir = tempdir().unwrap();
578 let agent = ClaudeAgent::new();
579 let scope = Scope::Local(dir.path().to_path_buf());
580 let spec = HookSpec::builder("alpha")
581 .command_program("noop", [] as [&str; 0])
582 .matcher(Matcher::Bash)
583 .event(Event::PreToolUse)
584 .build();
585 agent.install(&scope, &spec).unwrap();
586
587 let v = read_json(&dir.path().join(".claude/settings.json"));
588 let hook = &v["hooks"]["PreToolUse"][0]["hooks"][0];
589 assert_eq!(hook["type"], json!("command"));
590 assert_eq!(hook["command"], json!("noop"));
591 assert_eq!(hook["args"], json!([]));
592 }
593
594 #[test]
595 fn shell_unchecked_preserves_shell_string_form() {
596 let dir = tempdir().unwrap();
597 let agent = ClaudeAgent::new();
598 let scope = Scope::Local(dir.path().to_path_buf());
599 let spec = HookSpec::builder("alpha")
600 .command_shell_unchecked(r#"myapp hook "$REPO""#)
601 .matcher(Matcher::Bash)
602 .event(Event::PreToolUse)
603 .build();
604 agent.install(&scope, &spec).unwrap();
605
606 let v = read_json(&dir.path().join(".claude/settings.json"));
607 let hook = &v["hooks"]["PreToolUse"][0]["hooks"][0];
608 assert_eq!(hook["type"], json!("command"));
609 assert_eq!(hook["command"], json!(r#"myapp hook "$REPO""#));
610 assert!(hook.get("args").is_none());
611 }
612
613 #[test]
614 fn reinstall_patches_legacy_shell_rendered_owned_hook_to_exec_form() {
615 let dir = tempdir().unwrap();
616 let settings = dir.path().join(".claude/settings.json");
617 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
618 std::fs::write(
619 &settings,
620 r#"{
621 "hooks": {
622 "PreToolUse": [
623 {
624 "matcher": "Bash",
625 "hooks": [{ "type": "command", "command": "myapp hook" }],
626 "_agent_config_tag": "alpha"
627 }
628 ]
629 }
630}
631"#,
632 )
633 .unwrap();
634
635 let agent = ClaudeAgent::new();
636 let scope = Scope::Local(dir.path().to_path_buf());
637 let report = agent.install(&scope, &local_spec("alpha")).unwrap();
638
639 assert!(!report.already_installed);
640 assert!(report.patched.iter().any(|p| p == &settings));
641 let v = read_json(&settings);
642 let hook = &v["hooks"]["PreToolUse"][0]["hooks"][0];
643 assert_eq!(hook["command"], json!("myapp"));
644 assert_eq!(hook["args"], json!(["hook"]));
645 }
646
647 #[test]
648 fn install_is_idempotent() {
649 let dir = tempdir().unwrap();
650 let agent = ClaudeAgent::new();
651 let scope = Scope::Local(dir.path().to_path_buf());
652 let spec = local_spec("alpha");
653
654 let r1 = agent.install(&scope, &spec).unwrap();
655 let r2 = agent.install(&scope, &spec).unwrap();
656 assert!(!r1.already_installed);
657 assert!(r2.already_installed);
658 }
659
660 #[test]
661 fn install_preserves_user_authored_hooks() {
662 let dir = tempdir().unwrap();
663 let settings = dir.path().join(".claude/settings.json");
664 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
665 std::fs::write(
666 &settings,
667 r#"{
668 "hooks": {
669 "PreToolUse": [
670 { "matcher": "Edit", "hooks": [{ "type": "command", "command": "user-thing" }] }
671 ]
672 },
673 "permissions": { "allow": ["Read"] }
674}
675"#,
676 )
677 .unwrap();
678
679 let agent = ClaudeAgent::new();
680 let scope = Scope::Local(dir.path().to_path_buf());
681 agent.install(&scope, &local_spec("alpha")).unwrap();
682
683 let v = read_json(&settings);
684 let arr = v["hooks"]["PreToolUse"].as_array().unwrap();
685 assert_eq!(arr.len(), 2);
686 assert_eq!(v["permissions"]["allow"], json!(["Read"]));
687 assert!(dir.path().join(".claude/settings.json.bak").exists());
689 }
690
691 #[test]
692 fn install_with_rules_writes_claude_md_block() {
693 let dir = tempdir().unwrap();
694 let agent = ClaudeAgent::new();
695 let scope = Scope::Local(dir.path().to_path_buf());
696 let spec = HookSpec::builder("alpha")
697 .command_program("noop", [] as [&str; 0])
698 .matcher(Matcher::Bash)
699 .rules("Use myapp prefix.")
700 .build();
701 agent.install(&scope, &spec).unwrap();
702
703 let md = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
704 assert!(md.contains("<!-- BEGIN AGENT-CONFIG:alpha -->"));
705 assert!(md.contains("Use myapp prefix."));
706 assert!(md.contains("<!-- END AGENT-CONFIG:alpha -->"));
707 }
708
709 #[test]
710 fn uninstall_removes_hook_and_restores_backup_if_we_were_only_content() {
711 let dir = tempdir().unwrap();
712 let agent = ClaudeAgent::new();
713 let scope = Scope::Local(dir.path().to_path_buf());
714 agent.install(&scope, &local_spec("alpha")).unwrap();
715
716 let settings = dir.path().join(".claude/settings.json");
717 assert!(settings.exists());
718
719 agent.uninstall(&scope, "alpha").unwrap();
720 assert!(!settings.exists(), "empty settings.json removed");
721 }
722
723 #[test]
724 fn uninstall_preserves_user_hooks_after_removing_ours() {
725 let dir = tempdir().unwrap();
726 let settings = dir.path().join(".claude/settings.json");
727 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
728 std::fs::write(
729 &settings,
730 r#"{ "hooks": { "PreToolUse": [
731 { "matcher": "Edit", "hooks": [{ "type": "command", "command": "user-thing" }] }
732 ]}}"#,
733 )
734 .unwrap();
735
736 let agent = ClaudeAgent::new();
737 let scope = Scope::Local(dir.path().to_path_buf());
738 agent.install(&scope, &local_spec("alpha")).unwrap();
739 agent.uninstall(&scope, "alpha").unwrap();
740
741 let v = read_json(&settings);
742 let arr = v["hooks"]["PreToolUse"].as_array().unwrap();
743 assert_eq!(arr.len(), 1);
744 assert_eq!(arr[0]["matcher"], json!("Edit"));
745 }
746
747 #[test]
748 fn uninstall_unknown_tag_is_noop() {
749 let dir = tempdir().unwrap();
750 let agent = ClaudeAgent::new();
751 let scope = Scope::Local(dir.path().to_path_buf());
752 let r = agent.uninstall(&scope, "ghost").unwrap();
753 assert!(r.not_installed);
754 }
755
756 #[test]
757 fn matcher_any_of_pipes_join() {
758 assert_eq!(
759 matcher_to_claude(&Matcher::AnyOf(vec!["Edit".into(), "Write".into()])),
760 "Edit|Write"
761 );
762 }
763
764 #[test]
765 fn malformed_settings_json_aborts_with_typed_error() {
766 let dir = tempdir().unwrap();
767 let settings = dir.path().join(".claude/settings.json");
768 std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
769 std::fs::write(&settings, "{ this is not json").unwrap();
770
771 let agent = ClaudeAgent::new();
772 let scope = Scope::Local(dir.path().to_path_buf());
773 let err = agent.install(&scope, &local_spec("alpha")).unwrap_err();
774 assert!(matches!(err, AgentConfigError::JsonInvalid { .. }));
775 }
776
777 #[test]
778 fn custom_event_passes_through() {
779 let dir = tempdir().unwrap();
780 let agent = ClaudeAgent::new();
781 let scope = Scope::Local(dir.path().to_path_buf());
782 let spec = HookSpec::builder("alpha")
783 .command_program("noop", [] as [&str; 0])
784 .event(Event::Custom("myCustomEvent".into()))
785 .build();
786 agent.install(&scope, &spec).unwrap();
787 let v = read_json(&dir.path().join(".claude/settings.json"));
788 assert!(v["hooks"]["myCustomEvent"].is_array());
789 }
790
791 #[test]
792 fn install_report_paths_under_project_dir() {
793 let dir = tempdir().unwrap();
794 let agent = ClaudeAgent::new();
795 let scope = Scope::Local(dir.path().to_path_buf());
796 let r = agent.install(&scope, &local_spec("alpha")).unwrap();
797 assert!(!r.created.is_empty());
798 let path = &r.created[0];
799 assert!(path.starts_with(dir.path()));
800 assert!(path.ends_with(PathBuf::from(".claude").join("settings.json")));
801 }
802
803 fn local_mcp_spec(name: &str, owner: &str) -> McpSpec {
804 McpSpec::builder(name)
805 .owner(owner)
806 .stdio("npx", ["-y", "@modelcontextprotocol/server-github"])
807 .env_from_host("GITHUB_TOKEN")
808 .build()
809 }
810
811 #[test]
812 fn local_install_mcp_writes_dot_mcp_json_at_project_root() {
813 let dir = tempdir().unwrap();
814 let agent = ClaudeAgent::new();
815 let scope = Scope::Local(dir.path().to_path_buf());
816 agent
817 .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
818 .unwrap();
819 let cfg = dir.path().join(".mcp.json");
820 assert!(cfg.exists(), "expected {} to exist", cfg.display());
821 let v = read_json(&cfg);
822 assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
823 }
824
825 #[test]
826 fn install_mcp_does_not_touch_settings_or_dotclaude() {
827 let dir = tempdir().unwrap();
828 let agent = ClaudeAgent::new();
829 let scope = Scope::Local(dir.path().to_path_buf());
830 agent
831 .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
832 .unwrap();
833 assert!(!dir.path().join(".claude/settings.json").exists());
834 assert!(!dir.path().join(".claude.json").exists());
835 }
836
837 #[test]
838 fn install_mcp_idempotent() {
839 let dir = tempdir().unwrap();
840 let agent = ClaudeAgent::new();
841 let scope = Scope::Local(dir.path().to_path_buf());
842 let spec = local_mcp_spec("github", "myapp");
843 agent.install_mcp(&scope, &spec).unwrap();
844 let r2 = agent.install_mcp(&scope, &spec).unwrap();
845 assert!(r2.already_installed);
846 }
847
848 #[test]
849 fn install_mcp_coexists_with_hook_install() {
850 let dir = tempdir().unwrap();
851 let agent = ClaudeAgent::new();
852 let scope = Scope::Local(dir.path().to_path_buf());
853 agent.install(&scope, &local_spec("alpha")).unwrap();
854 agent
855 .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
856 .unwrap();
857 assert!(dir.path().join(".claude/settings.json").exists());
859 assert!(dir.path().join(".mcp.json").exists());
860 }
861
862 #[test]
863 fn uninstall_mcp_owner_mismatch_refused() {
864 let dir = tempdir().unwrap();
865 let agent = ClaudeAgent::new();
866 let scope = Scope::Local(dir.path().to_path_buf());
867 agent
868 .install_mcp(&scope, &local_mcp_spec("github", "appA"))
869 .unwrap();
870 let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
871 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
872 }
873
874 #[test]
875 fn uninstall_mcp_round_trip() {
876 let dir = tempdir().unwrap();
877 let agent = ClaudeAgent::new();
878 let scope = Scope::Local(dir.path().to_path_buf());
879 agent
880 .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
881 .unwrap();
882 assert!(agent.is_mcp_installed(&scope, "github").unwrap());
883 agent.uninstall_mcp(&scope, "github", "myapp").unwrap();
884 assert!(!agent.is_mcp_installed(&scope, "github").unwrap());
885 assert!(!dir.path().join(".mcp.json").exists());
886 }
887
888 #[test]
889 fn install_mcp_invalid_name_rejected() {
890 let dir = tempdir().unwrap();
891 let agent = ClaudeAgent::new();
892 let scope = Scope::Local(dir.path().to_path_buf());
893 let spec = McpSpec {
895 name: "bad name".into(),
896 owner_tag: "myapp".into(),
897 transport: crate::spec::McpTransport::Stdio {
898 command: "x".into(),
899 args: vec![],
900 env: Default::default(),
901 },
902 friendly_name: None,
903 secret_policy: crate::spec::SecretPolicy::RefuseInlineSecretsInLocalScope,
904 adopt_unowned: false,
905 };
906 let err = agent.install_mcp(&scope, &spec).unwrap_err();
907 assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
908 }
909}