Skip to main content

agent_config/agents/claude/
mod.rs

1//! Claude Code integration.
2//!
3//! Hook surface: `<scope>/.claude/settings.json` with the JSON envelope:
4//!
5//! ```json
6//! {
7//!   "hooks": {
8//!     "PreToolUse": [
9//!       {
10//!         "matcher": "Bash",
11//!         "hooks": [{ "type": "command", "command": "..." }],
12//!         "_agent_config_tag": "myapp"
13//!       }
14//!     ]
15//!   }
16//! }
17//! ```
18//!
19//! Optional prompt surface: `~/.claude/CLAUDE.md` (Global) or
20//! `<project>/CLAUDE.md` (Local), with a tagged HTML-comment fence.
21//!
22//! Instructions surface (the only `ReferencedFile` placement in the crate)
23//! lives in `instructions.rs`.
24
25use 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, 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/// Claude Code (Anthropic's official CLI).
45#[derive(Debug, Clone, Copy, Default)]
46pub struct ClaudeAgent {
47    _private: (),
48}
49
50impl ClaudeAgent {
51    /// Construct an instance. The struct is stateless.
52    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    /// Path to the MCP config file for the given scope.
71    ///
72    /// Global → `~/.claude.json`.
73    ///
74    /// Local → `<root>/.mcp.json` (the canonical project-shared MCP file
75    /// Anthropic's own CLI writes; *not* under `.claude/`).
76    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    /// `~/.claude/skills/` (Global) or `<root>/.claude/skills/` (Local).
84    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": [{ "type": "command", "command": spec.command.render_shell() }],
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": [{ "type": "command", "command": spec.command.render_shell() }],
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
496/// Claude treats matchers as exact tool-name match when the string contains
497/// only `[A-Za-z0-9_|]`; anything else makes it a JS regex. We pass `Regex`
498/// through verbatim and let the user own that.
499fn 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    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use serde_json::{json, Value};
521    use tempfile::tempdir;
522
523    fn local_spec(tag: &str) -> HookSpec {
524        HookSpec::builder(tag)
525            .command_program("myapp", ["hook"])
526            .matcher(Matcher::Bash)
527            .event(Event::PreToolUse)
528            .build()
529    }
530
531    fn read_json(p: &std::path::Path) -> Value {
532        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
533    }
534
535    #[test]
536    fn local_install_writes_settings_json_with_correct_shape() {
537        let dir = tempdir().unwrap();
538        let agent = ClaudeAgent::new();
539        let scope = Scope::Local(dir.path().to_path_buf());
540        agent.install(&scope, &local_spec("alpha")).unwrap();
541
542        let p = dir.path().join(".claude/settings.json");
543        let v = read_json(&p);
544        assert_eq!(v["hooks"]["PreToolUse"][0]["matcher"], json!("Bash"));
545        assert_eq!(
546            v["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
547            json!("myapp hook")
548        );
549        assert_eq!(
550            v["hooks"]["PreToolUse"][0]["hooks"][0]["type"],
551            json!("command")
552        );
553        assert_eq!(
554            v["hooks"]["PreToolUse"][0]["_agent_config_tag"],
555            json!("alpha")
556        );
557    }
558
559    #[test]
560    fn install_is_idempotent() {
561        let dir = tempdir().unwrap();
562        let agent = ClaudeAgent::new();
563        let scope = Scope::Local(dir.path().to_path_buf());
564        let spec = local_spec("alpha");
565
566        let r1 = agent.install(&scope, &spec).unwrap();
567        let r2 = agent.install(&scope, &spec).unwrap();
568        assert!(!r1.already_installed);
569        assert!(r2.already_installed);
570    }
571
572    #[test]
573    fn install_preserves_user_authored_hooks() {
574        let dir = tempdir().unwrap();
575        let settings = dir.path().join(".claude/settings.json");
576        std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
577        std::fs::write(
578            &settings,
579            r#"{
580  "hooks": {
581    "PreToolUse": [
582      { "matcher": "Edit", "hooks": [{ "type": "command", "command": "user-thing" }] }
583    ]
584  },
585  "permissions": { "allow": ["Read"] }
586}
587"#,
588        )
589        .unwrap();
590
591        let agent = ClaudeAgent::new();
592        let scope = Scope::Local(dir.path().to_path_buf());
593        agent.install(&scope, &local_spec("alpha")).unwrap();
594
595        let v = read_json(&settings);
596        let arr = v["hooks"]["PreToolUse"].as_array().unwrap();
597        assert_eq!(arr.len(), 2);
598        assert_eq!(v["permissions"]["allow"], json!(["Read"]));
599        // Backup was made.
600        assert!(dir.path().join(".claude/settings.json.bak").exists());
601    }
602
603    #[test]
604    fn install_with_rules_writes_claude_md_block() {
605        let dir = tempdir().unwrap();
606        let agent = ClaudeAgent::new();
607        let scope = Scope::Local(dir.path().to_path_buf());
608        let spec = HookSpec::builder("alpha")
609            .command_program("noop", [] as [&str; 0])
610            .matcher(Matcher::Bash)
611            .rules("Use myapp prefix.")
612            .build();
613        agent.install(&scope, &spec).unwrap();
614
615        let md = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
616        assert!(md.contains("<!-- BEGIN AGENT-CONFIG:alpha -->"));
617        assert!(md.contains("Use myapp prefix."));
618        assert!(md.contains("<!-- END AGENT-CONFIG:alpha -->"));
619    }
620
621    #[test]
622    fn uninstall_removes_hook_and_restores_backup_if_we_were_only_content() {
623        let dir = tempdir().unwrap();
624        let agent = ClaudeAgent::new();
625        let scope = Scope::Local(dir.path().to_path_buf());
626        agent.install(&scope, &local_spec("alpha")).unwrap();
627
628        let settings = dir.path().join(".claude/settings.json");
629        assert!(settings.exists());
630
631        agent.uninstall(&scope, "alpha").unwrap();
632        assert!(!settings.exists(), "empty settings.json removed");
633    }
634
635    #[test]
636    fn uninstall_preserves_user_hooks_after_removing_ours() {
637        let dir = tempdir().unwrap();
638        let settings = dir.path().join(".claude/settings.json");
639        std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
640        std::fs::write(
641            &settings,
642            r#"{ "hooks": { "PreToolUse": [
643              { "matcher": "Edit", "hooks": [{ "type": "command", "command": "user-thing" }] }
644            ]}}"#,
645        )
646        .unwrap();
647
648        let agent = ClaudeAgent::new();
649        let scope = Scope::Local(dir.path().to_path_buf());
650        agent.install(&scope, &local_spec("alpha")).unwrap();
651        agent.uninstall(&scope, "alpha").unwrap();
652
653        let v = read_json(&settings);
654        let arr = v["hooks"]["PreToolUse"].as_array().unwrap();
655        assert_eq!(arr.len(), 1);
656        assert_eq!(arr[0]["matcher"], json!("Edit"));
657    }
658
659    #[test]
660    fn uninstall_unknown_tag_is_noop() {
661        let dir = tempdir().unwrap();
662        let agent = ClaudeAgent::new();
663        let scope = Scope::Local(dir.path().to_path_buf());
664        let r = agent.uninstall(&scope, "ghost").unwrap();
665        assert!(r.not_installed);
666    }
667
668    #[test]
669    fn matcher_any_of_pipes_join() {
670        assert_eq!(
671            matcher_to_claude(&Matcher::AnyOf(vec!["Edit".into(), "Write".into()])),
672            "Edit|Write"
673        );
674    }
675
676    #[test]
677    fn malformed_settings_json_aborts_with_typed_error() {
678        let dir = tempdir().unwrap();
679        let settings = dir.path().join(".claude/settings.json");
680        std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
681        std::fs::write(&settings, "{ this is not json").unwrap();
682
683        let agent = ClaudeAgent::new();
684        let scope = Scope::Local(dir.path().to_path_buf());
685        let err = agent.install(&scope, &local_spec("alpha")).unwrap_err();
686        assert!(matches!(err, AgentConfigError::JsonInvalid { .. }));
687    }
688
689    #[test]
690    fn custom_event_passes_through() {
691        let dir = tempdir().unwrap();
692        let agent = ClaudeAgent::new();
693        let scope = Scope::Local(dir.path().to_path_buf());
694        let spec = HookSpec::builder("alpha")
695            .command_program("noop", [] as [&str; 0])
696            .event(Event::Custom("myCustomEvent".into()))
697            .build();
698        agent.install(&scope, &spec).unwrap();
699        let v = read_json(&dir.path().join(".claude/settings.json"));
700        assert!(v["hooks"]["myCustomEvent"].is_array());
701    }
702
703    #[test]
704    fn install_report_paths_under_project_dir() {
705        let dir = tempdir().unwrap();
706        let agent = ClaudeAgent::new();
707        let scope = Scope::Local(dir.path().to_path_buf());
708        let r = agent.install(&scope, &local_spec("alpha")).unwrap();
709        assert!(!r.created.is_empty());
710        let path = &r.created[0];
711        assert!(path.starts_with(dir.path()));
712        assert!(path.ends_with(PathBuf::from(".claude").join("settings.json")));
713    }
714
715    fn local_mcp_spec(name: &str, owner: &str) -> McpSpec {
716        McpSpec::builder(name)
717            .owner(owner)
718            .stdio("npx", ["-y", "@modelcontextprotocol/server-github"])
719            .env_from_host("GITHUB_TOKEN")
720            .build()
721    }
722
723    #[test]
724    fn local_install_mcp_writes_dot_mcp_json_at_project_root() {
725        let dir = tempdir().unwrap();
726        let agent = ClaudeAgent::new();
727        let scope = Scope::Local(dir.path().to_path_buf());
728        agent
729            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
730            .unwrap();
731        let cfg = dir.path().join(".mcp.json");
732        assert!(cfg.exists(), "expected {} to exist", cfg.display());
733        let v = read_json(&cfg);
734        assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
735    }
736
737    #[test]
738    fn install_mcp_does_not_touch_settings_or_dotclaude() {
739        let dir = tempdir().unwrap();
740        let agent = ClaudeAgent::new();
741        let scope = Scope::Local(dir.path().to_path_buf());
742        agent
743            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
744            .unwrap();
745        assert!(!dir.path().join(".claude/settings.json").exists());
746        assert!(!dir.path().join(".claude.json").exists());
747    }
748
749    #[test]
750    fn install_mcp_idempotent() {
751        let dir = tempdir().unwrap();
752        let agent = ClaudeAgent::new();
753        let scope = Scope::Local(dir.path().to_path_buf());
754        let spec = local_mcp_spec("github", "myapp");
755        agent.install_mcp(&scope, &spec).unwrap();
756        let r2 = agent.install_mcp(&scope, &spec).unwrap();
757        assert!(r2.already_installed);
758    }
759
760    #[test]
761    fn install_mcp_coexists_with_hook_install() {
762        let dir = tempdir().unwrap();
763        let agent = ClaudeAgent::new();
764        let scope = Scope::Local(dir.path().to_path_buf());
765        agent.install(&scope, &local_spec("alpha")).unwrap();
766        agent
767            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
768            .unwrap();
769        // Hooks live in .claude/settings.json; MCP lives in .mcp.json — separate files.
770        assert!(dir.path().join(".claude/settings.json").exists());
771        assert!(dir.path().join(".mcp.json").exists());
772    }
773
774    #[test]
775    fn uninstall_mcp_owner_mismatch_refused() {
776        let dir = tempdir().unwrap();
777        let agent = ClaudeAgent::new();
778        let scope = Scope::Local(dir.path().to_path_buf());
779        agent
780            .install_mcp(&scope, &local_mcp_spec("github", "appA"))
781            .unwrap();
782        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
783        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
784    }
785
786    #[test]
787    fn uninstall_mcp_round_trip() {
788        let dir = tempdir().unwrap();
789        let agent = ClaudeAgent::new();
790        let scope = Scope::Local(dir.path().to_path_buf());
791        agent
792            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
793            .unwrap();
794        assert!(agent.is_mcp_installed(&scope, "github").unwrap());
795        agent.uninstall_mcp(&scope, "github", "myapp").unwrap();
796        assert!(!agent.is_mcp_installed(&scope, "github").unwrap());
797        assert!(!dir.path().join(".mcp.json").exists());
798    }
799
800    #[test]
801    fn install_mcp_invalid_name_rejected() {
802        let dir = tempdir().unwrap();
803        let agent = ClaudeAgent::new();
804        let scope = Scope::Local(dir.path().to_path_buf());
805        // Build a spec by skipping the validating builder.
806        let spec = McpSpec {
807            name: "bad name".into(),
808            owner_tag: "myapp".into(),
809            transport: crate::spec::McpTransport::Stdio {
810                command: "x".into(),
811                args: vec![],
812                env: Default::default(),
813            },
814            friendly_name: None,
815            secret_policy: crate::spec::SecretPolicy::RefuseInlineSecretsInLocalScope,
816            adopt_unowned: false,
817        };
818        let err = agent.install_mcp(&scope, &spec).unwrap_err();
819        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
820    }
821}