Skip to main content

agent_config/agents/
windsurf.rs

1//! Windsurf (Codeium Cascade) integration.
2//!
3//! Three surfaces:
4//!
5//! 1. **Rules** — project-local markdown files at `.windsurf/rules/<tag>.md`.
6//!
7//! 2. **Hooks** — JSON config at `.windsurf/hooks.json` (Local). Each event
8//!    key (e.g. `pre_run_command`, `post_cascade_response`) maps to an array
9//!    of `{ "bash": "...", "_agent_config_tag": "..." }` entries; multiple
10//!    consumers coexist via the standard tagged-array helper.
11//!
12//! 3. **MCP servers** — JSON config at `.windsurf/mcp_config.json` (Local) or
13//!    `~/.codeium/windsurf/mcp_config.json` (Global), keyed by server name
14//!    under `mcpServers`. Same shape as Claude/Cursor; reuses
15//!    `util::mcp_json_object`.
16//!
17//! 4. **Skills** — directory-scoped skills at `.windsurf/skills/<name>/`
18//!    (Local) or `~/.codeium/windsurf/skills/<name>/` (Global).
19
20use std::path::PathBuf;
21
22use serde_json::json;
23
24use crate::agents::planning as agent_planning;
25use crate::error::AgentConfigError;
26use crate::integration::{
27    InstallReport, InstructionSurface, Integration, McpSurface, SkillSurface, UninstallReport,
28};
29use crate::paths;
30use crate::plan::{
31    has_refusal, InstallPlan, PlanTarget as DryPlanTarget, RefusalReason, UninstallPlan,
32};
33use crate::scope::{Scope, ScopeKind};
34use crate::spec::{Event, HookSpec, InstructionSpec, McpSpec, SkillSpec};
35use crate::status::{ConfigPresence, InstallStatus, PathStatus, PlanTarget, StatusReport};
36use crate::util::{
37    file_lock, instructions_dir, json_patch, mcp_json_object, ownership, planning, rules_dir,
38    safe_fs, skills_dir,
39};
40
41const RULES_DIR: &str = ".windsurf/rules";
42
43/// Windsurf integration.
44#[derive(Debug, Clone, Copy, Default)]
45pub struct WindsurfAgent {
46    _private: (),
47}
48
49impl WindsurfAgent {
50    /// Construct an instance. Stateless.
51    pub const fn new() -> Self {
52        Self { _private: () }
53    }
54
55    fn project_root<'a>(&self, scope: &'a Scope) -> Result<&'a std::path::Path, AgentConfigError> {
56        match scope {
57            Scope::Local(p) => Ok(p),
58            Scope::Global => Err(AgentConfigError::UnsupportedScope {
59                id: "windsurf",
60                scope: ScopeKind::Global,
61            }),
62        }
63    }
64
65    fn hooks_path(&self, scope: &Scope) -> Result<PathBuf, AgentConfigError> {
66        Ok(self.project_root(scope)?.join(".windsurf/hooks.json"))
67    }
68
69    fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
70        Ok(match scope {
71            Scope::Global => paths::windsurf_mcp_global_file()?,
72            Scope::Local(p) => p.join(".windsurf/mcp_config.json"),
73        })
74    }
75
76    fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
77        Ok(match scope {
78            Scope::Global => paths::home_dir()?
79                .join(".codeium")
80                .join("windsurf")
81                .join("skills"),
82            Scope::Local(p) => p.join(".windsurf").join("skills"),
83        })
84    }
85}
86
87impl Integration for WindsurfAgent {
88    fn id(&self) -> &'static str {
89        "windsurf"
90    }
91
92    fn display_name(&self) -> &'static str {
93        "Windsurf"
94    }
95
96    fn supported_scopes(&self) -> &'static [ScopeKind] {
97        &[ScopeKind::Local]
98    }
99
100    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
101        HookSpec::validate_tag(tag)?;
102        let root = self.project_root(scope)?;
103        let rules_file = rules_dir::target_path(root, RULES_DIR, tag);
104        let rules_exists = rules_file.exists();
105
106        let hooks_file = self.hooks_path(scope)?;
107        let presence = json_patch::tagged_hook_presence(&hooks_file, &[], tag)?;
108
109        let hook_present = matches!(
110            presence,
111            ConfigPresence::Single | ConfigPresence::Duplicate { .. }
112        );
113
114        let status = if rules_exists || hook_present {
115            InstallStatus::InstalledOwned {
116                owner: tag.to_string(),
117            }
118        } else if let ConfigPresence::Invalid { reason } = &presence {
119            InstallStatus::Drifted {
120                issues: vec![crate::status::DriftIssue::InvalidConfig {
121                    path: hooks_file.clone(),
122                    reason: reason.clone(),
123                }],
124            }
125        } else {
126            InstallStatus::Absent
127        };
128
129        let mut files = vec![if rules_exists {
130            PathStatus::Exists {
131                path: rules_file.clone(),
132            }
133        } else {
134            PathStatus::Missing {
135                path: rules_file.clone(),
136            }
137        }];
138        files.push(if hooks_file.exists() {
139            PathStatus::Exists {
140                path: hooks_file.clone(),
141            }
142        } else {
143            PathStatus::Missing {
144                path: hooks_file.clone(),
145            }
146        });
147
148        Ok(StatusReport {
149            target: PlanTarget::Hook {
150                tag: tag.to_string(),
151            },
152            status,
153            config_path: Some(hooks_file),
154            ledger_path: None,
155            files,
156            warnings: Vec::new(),
157        })
158    }
159
160    fn plan_install(
161        &self,
162        scope: &Scope,
163        spec: &HookSpec,
164    ) -> Result<InstallPlan, AgentConfigError> {
165        HookSpec::validate_tag(&spec.tag)?;
166        let target = DryPlanTarget::Hook {
167            integration_id: Integration::id(self),
168            scope: scope.clone(),
169            tag: spec.tag.clone(),
170        };
171        let root = match self.project_root(scope) {
172            Ok(root) => root,
173            Err(AgentConfigError::UnsupportedScope { .. }) => {
174                return Ok(InstallPlan::refused(
175                    target,
176                    None,
177                    RefusalReason::UnsupportedScope,
178                ));
179            }
180            Err(e) => return Err(e),
181        };
182        let mut changes = Vec::new();
183        if let Some(rules) = &spec.rules {
184            changes.extend(rules_dir::plan_install(
185                root,
186                RULES_DIR,
187                &spec.tag,
188                &rules.content,
189            )?);
190        }
191        if has_refusal(&changes) {
192            return Ok(InstallPlan::from_changes(target, changes));
193        }
194
195        if spec.script.is_some() || spec.rules.is_none() {
196            let event_key = event_to_windsurf(&spec.event);
197            let p = self.hooks_path(scope)?;
198            let entry = json!({
199                "bash": spec.command.render_shell(),
200            });
201            planning::plan_tagged_json_upsert(
202                &mut changes,
203                &p,
204                &[event_key.as_str()],
205                &spec.tag,
206                entry,
207                |_| {},
208            )?;
209        }
210
211        Ok(InstallPlan::from_changes(target, changes))
212    }
213
214    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
215        HookSpec::validate_tag(tag)?;
216        let target = DryPlanTarget::Hook {
217            integration_id: Integration::id(self),
218            scope: scope.clone(),
219            tag: tag.to_string(),
220        };
221        let root = match self.project_root(scope) {
222            Ok(root) => root,
223            Err(AgentConfigError::UnsupportedScope { .. }) => {
224                return Ok(UninstallPlan::refused(
225                    target,
226                    None,
227                    RefusalReason::UnsupportedScope,
228                ));
229            }
230            Err(e) => return Err(e),
231        };
232        let mut changes = rules_dir::plan_uninstall(root, RULES_DIR, tag)?;
233        let p = self.hooks_path(scope)?;
234        planning::plan_tagged_json_remove_under(
235            &mut changes,
236            &p,
237            &[],
238            tag,
239            planning::json_object_empty,
240            false,
241        )?;
242        Ok(UninstallPlan::from_changes(target, changes))
243    }
244
245    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
246        HookSpec::validate_tag(&spec.tag)?;
247        let root = self.project_root(scope)?;
248        let mut report = InstallReport::default();
249
250        if let Some(rules) = &spec.rules {
251            scope.ensure_contained(&rules_dir::target_path(root, RULES_DIR, &spec.tag))?;
252            let r = rules_dir::install(scope, RULES_DIR, &spec.tag, &rules.content)?;
253            report.merge(r);
254        }
255
256        // Hook entry — written when caller didn't ask for rules-only by
257        // explicitly supplying nothing else, OR when they supplied a script.
258        if spec.script.is_some() || spec.rules.is_none() {
259            let event_key = event_to_windsurf(&spec.event);
260            let p = self.hooks_path(scope)?;
261            scope.ensure_contained(&p)?;
262            file_lock::with_lock(&p, || {
263                let mut root_doc = json_patch::read_or_empty(&p)?;
264                let entry = json!({
265                    "bash": spec.command.render_shell(),
266                });
267                let changed = json_patch::upsert_tagged_array_entry(
268                    &mut root_doc,
269                    &[event_key.as_str()],
270                    &spec.tag,
271                    entry,
272                )?;
273                if changed {
274                    let bytes = json_patch::to_pretty(&root_doc);
275                    let outcome = safe_fs::write(scope, &p, &bytes, true)?;
276                    if outcome.existed {
277                        report.patched.push(outcome.path.clone());
278                    } else {
279                        report.created.push(outcome.path.clone());
280                    }
281                    if let Some(b) = outcome.backup {
282                        report.backed_up.push(b);
283                    }
284                    report.already_installed = false;
285                } else if report.created.is_empty() && report.patched.is_empty() {
286                    report.already_installed = true;
287                }
288                Ok::<(), AgentConfigError>(())
289            })?;
290        }
291
292        Ok(report)
293    }
294
295    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
296        HookSpec::validate_tag(tag)?;
297        let root = self.project_root(scope)?;
298        let mut report = UninstallReport::default();
299
300        scope.ensure_contained(&rules_dir::target_path(root, RULES_DIR, tag))?;
301        let r = rules_dir::uninstall(scope, RULES_DIR, tag)?;
302        report.merge(r);
303
304        let p = self.hooks_path(scope)?;
305        scope.ensure_contained(&p)?;
306        if p.exists() {
307            file_lock::with_lock(&p, || {
308                let mut doc = json_patch::read_or_empty(&p)?;
309                let changed = json_patch::remove_tagged_array_entries_under(&mut doc, &[], tag)?;
310                if changed {
311                    let now_empty = doc.as_object().map(|o| o.is_empty()).unwrap_or(true);
312                    if now_empty {
313                        // The file holds only tagged entries; once they're all
314                        // gone the `.bak` snapshots an intermediate multi-event
315                        // install of our own, not pre-install user content.
316                        safe_fs::remove_file(scope, &p)?;
317                        safe_fs::remove_backup_if_exists(scope, &p)?;
318                        report.removed.push(p.clone());
319                    } else {
320                        let bytes = json_patch::to_pretty(&doc);
321                        safe_fs::write(scope, &p, &bytes, false)?;
322                        report.patched.push(p.clone());
323                    }
324                }
325                Ok::<(), AgentConfigError>(())
326            })?;
327        }
328
329        if report.removed.is_empty() && report.patched.is_empty() && report.restored.is_empty() {
330            report.not_installed = true;
331        }
332        Ok(report)
333    }
334}
335
336impl McpSurface for WindsurfAgent {
337    fn id(&self) -> &'static str {
338        "windsurf"
339    }
340
341    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
342        &[ScopeKind::Global, ScopeKind::Local]
343    }
344
345    fn mcp_status(
346        &self,
347        scope: &Scope,
348        name: &str,
349        expected_owner: &str,
350    ) -> Result<StatusReport, AgentConfigError> {
351        McpSpec::validate_name(name)?;
352        let cfg = Self::mcp_path(scope)?;
353        let ledger = ownership::mcp_ledger_for(&cfg);
354        let presence = mcp_json_object::config_presence(&cfg, name)?;
355        let recorded = ownership::owner_of(&ledger, name)?;
356        Ok(StatusReport::for_mcp(
357            name,
358            cfg,
359            ledger,
360            presence,
361            expected_owner,
362            recorded,
363        ))
364    }
365
366    fn plan_install_mcp(
367        &self,
368        scope: &Scope,
369        spec: &McpSpec,
370    ) -> Result<InstallPlan, AgentConfigError> {
371        agent_planning::mcp_json_object_install(
372            McpSurface::id(self),
373            scope,
374            spec,
375            Self::mcp_path(scope),
376        )
377    }
378
379    fn plan_uninstall_mcp(
380        &self,
381        scope: &Scope,
382        name: &str,
383        owner_tag: &str,
384    ) -> Result<UninstallPlan, AgentConfigError> {
385        agent_planning::mcp_json_object_uninstall(
386            McpSurface::id(self),
387            scope,
388            name,
389            owner_tag,
390            Self::mcp_path(scope),
391        )
392    }
393
394    fn install_mcp(
395        &self,
396        scope: &Scope,
397        spec: &McpSpec,
398    ) -> Result<InstallReport, AgentConfigError> {
399        spec.validate()?;
400        let cfg = Self::mcp_path(scope)?;
401        spec.validate_local_secret_policy(scope)?;
402        scope.ensure_contained(&cfg)?;
403        let ledger = ownership::mcp_ledger_for(&cfg);
404        mcp_json_object::install(&cfg, &ledger, spec)
405    }
406
407    fn uninstall_mcp(
408        &self,
409        scope: &Scope,
410        name: &str,
411        owner_tag: &str,
412    ) -> Result<UninstallReport, AgentConfigError> {
413        McpSpec::validate_name(name)?;
414        HookSpec::validate_tag(owner_tag)?;
415        let cfg = Self::mcp_path(scope)?;
416        scope.ensure_contained(&cfg)?;
417        let ledger = ownership::mcp_ledger_for(&cfg);
418        mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
419    }
420}
421
422impl SkillSurface for WindsurfAgent {
423    fn id(&self) -> &'static str {
424        "windsurf"
425    }
426
427    fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
428        &[ScopeKind::Global, ScopeKind::Local]
429    }
430
431    fn skill_status(
432        &self,
433        scope: &Scope,
434        name: &str,
435        expected_owner: &str,
436    ) -> Result<StatusReport, AgentConfigError> {
437        SkillSpec::validate_name(name)?;
438        let root = Self::skills_root(scope)?;
439        let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
440        let recorded = ownership::owner_of(&ledger, name)?;
441        Ok(StatusReport::for_skill(
442            name,
443            dir,
444            manifest,
445            ledger,
446            expected_owner,
447            recorded,
448        ))
449    }
450
451    fn plan_install_skill(
452        &self,
453        scope: &Scope,
454        spec: &SkillSpec,
455    ) -> Result<InstallPlan, AgentConfigError> {
456        agent_planning::skill_install(
457            SkillSurface::id(self),
458            scope,
459            spec,
460            Self::skills_root(scope),
461        )
462    }
463
464    fn plan_uninstall_skill(
465        &self,
466        scope: &Scope,
467        name: &str,
468        owner_tag: &str,
469    ) -> Result<UninstallPlan, AgentConfigError> {
470        agent_planning::skill_uninstall(
471            SkillSurface::id(self),
472            scope,
473            name,
474            owner_tag,
475            Self::skills_root(scope),
476        )
477    }
478
479    fn install_skill(
480        &self,
481        scope: &Scope,
482        spec: &SkillSpec,
483    ) -> Result<InstallReport, AgentConfigError> {
484        let root = Self::skills_root(scope)?;
485        scope.ensure_contained(&root)?;
486        skills_dir::install(&root, spec)
487    }
488
489    fn uninstall_skill(
490        &self,
491        scope: &Scope,
492        name: &str,
493        owner_tag: &str,
494    ) -> Result<UninstallReport, AgentConfigError> {
495        let root = Self::skills_root(scope)?;
496        scope.ensure_contained(&root)?;
497        skills_dir::uninstall(&root, name, owner_tag)
498    }
499}
500
501impl WindsurfAgent {
502    fn standalone_layout(
503        &self,
504        scope: &Scope,
505    ) -> Result<instructions_dir::StandaloneLayout, AgentConfigError> {
506        let root = self.project_root(scope)?;
507        Ok(instructions_dir::StandaloneLayout {
508            config_dir: root.join(".windsurf"),
509            instruction_dir: root.join(".windsurf/rules"),
510        })
511    }
512}
513
514impl InstructionSurface for WindsurfAgent {
515    fn id(&self) -> &'static str {
516        "windsurf"
517    }
518
519    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
520        &[ScopeKind::Local]
521    }
522
523    fn instruction_status(
524        &self,
525        scope: &Scope,
526        name: &str,
527        expected_owner: &str,
528    ) -> Result<StatusReport, AgentConfigError> {
529        instructions_dir::standalone_status(self.standalone_layout(scope)?, name, expected_owner)
530    }
531
532    fn plan_install_instruction(
533        &self,
534        scope: &Scope,
535        spec: &InstructionSpec,
536    ) -> Result<InstallPlan, AgentConfigError> {
537        instructions_dir::standalone_plan_install(
538            InstructionSurface::id(self),
539            scope,
540            self.standalone_layout(scope),
541            spec,
542        )
543    }
544
545    fn plan_uninstall_instruction(
546        &self,
547        scope: &Scope,
548        name: &str,
549        owner_tag: &str,
550    ) -> Result<UninstallPlan, AgentConfigError> {
551        instructions_dir::standalone_plan_uninstall(
552            InstructionSurface::id(self),
553            scope,
554            self.standalone_layout(scope),
555            name,
556            owner_tag,
557        )
558    }
559
560    fn install_instruction(
561        &self,
562        scope: &Scope,
563        spec: &InstructionSpec,
564    ) -> Result<InstallReport, AgentConfigError> {
565        instructions_dir::standalone_install(scope, self.standalone_layout(scope)?, spec)
566    }
567
568    fn uninstall_instruction(
569        &self,
570        scope: &Scope,
571        name: &str,
572        owner_tag: &str,
573    ) -> Result<UninstallReport, AgentConfigError> {
574        instructions_dir::standalone_uninstall(
575            scope,
576            self.standalone_layout(scope)?,
577            name,
578            owner_tag,
579        )
580    }
581}
582
583/// Map [`Event`] to Windsurf's hook key. Windsurf uses snake_case event
584/// names (`pre_run_command`, `post_cascade_response`, etc.); the
585/// PreToolUse/PostToolUse defaults map to the closest equivalents. Use
586/// [`Event::Custom`] for anything else.
587fn event_to_windsurf(event: &Event) -> String {
588    match event {
589        Event::PreToolUse => "pre_run_command".into(),
590        Event::PostToolUse => "post_cascade_response".into(),
591        Event::Custom(s) => s.clone(),
592    }
593}
594
595/// Event keys documented by Windsurf. Kept for tests and documentation; hook
596/// detection/removal scans all top-level arrays so custom events are covered.
597#[cfg(test)]
598fn known_event_keys() -> &'static [&'static str] {
599    &[
600        "pre_user_prompt",
601        "pre_read_code",
602        "pre_write_code",
603        "pre_run_command",
604        "pre_mcp_tool_use",
605        "post_cascade_response",
606        "post_user_prompt",
607        "post_read_code",
608        "post_write_code",
609        "post_run_command",
610        "post_mcp_tool_use",
611    ]
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use crate::spec::InstructionPlacement;
618    use serde_json::Value;
619    use std::fs;
620    use tempfile::tempdir;
621
622    fn read_json(p: &std::path::Path) -> Value {
623        serde_json::from_slice(&fs::read(p).unwrap()).unwrap()
624    }
625
626    fn rules_spec(tag: &str, body: &str) -> HookSpec {
627        HookSpec::builder(tag)
628            .command_program("noop", [] as [&str; 0])
629            .rules(body)
630            .build()
631    }
632
633    fn hook_spec(tag: &str, event: Event, command: &str) -> HookSpec {
634        HookSpec::builder(tag)
635            .command_shell_unchecked(command)
636            .event(event)
637            .build()
638    }
639
640    #[test]
641    fn install_rules_writes_dot_windsurf_rules_file() {
642        let dir = tempdir().unwrap();
643        let agent = WindsurfAgent::new();
644        let scope = Scope::Local(dir.path().to_path_buf());
645        agent.install(&scope, &rules_spec("alpha", "body")).unwrap();
646        assert!(dir.path().join(".windsurf/rules/alpha.md").exists());
647    }
648
649    #[test]
650    fn install_default_event_writes_pre_run_command_entry() {
651        let dir = tempdir().unwrap();
652        let agent = WindsurfAgent::new();
653        let scope = Scope::Local(dir.path().to_path_buf());
654        agent
655            .install(&scope, &hook_spec("alpha", Event::PreToolUse, "myapp hook"))
656            .unwrap();
657        let v = read_json(&dir.path().join(".windsurf/hooks.json"));
658        let arr = v["pre_run_command"].as_array().unwrap();
659        assert_eq!(arr.len(), 1);
660        assert_eq!(arr[0]["bash"], serde_json::json!("myapp hook"));
661        assert_eq!(arr[0]["_agent_config_tag"], serde_json::json!("alpha"));
662    }
663
664    #[test]
665    fn install_custom_event_passes_through() {
666        let dir = tempdir().unwrap();
667        let agent = WindsurfAgent::new();
668        let scope = Scope::Local(dir.path().to_path_buf());
669        agent
670            .install(
671                &scope,
672                &hook_spec("alpha", Event::Custom("pre_write_code".into()), "x"),
673            )
674            .unwrap();
675        let v = read_json(&dir.path().join(".windsurf/hooks.json"));
676        assert!(v["pre_write_code"].is_array());
677    }
678
679    #[test]
680    fn install_idempotent() {
681        let dir = tempdir().unwrap();
682        let agent = WindsurfAgent::new();
683        let scope = Scope::Local(dir.path().to_path_buf());
684        let s = hook_spec("alpha", Event::PreToolUse, "x");
685        agent.install(&scope, &s).unwrap();
686        let r2 = agent.install(&scope, &s).unwrap();
687        assert!(r2.already_installed);
688    }
689
690    #[test]
691    fn install_coexists_with_other_consumer() {
692        let dir = tempdir().unwrap();
693        let agent = WindsurfAgent::new();
694        let scope = Scope::Local(dir.path().to_path_buf());
695        agent
696            .install(&scope, &hook_spec("appA", Event::PreToolUse, "a"))
697            .unwrap();
698        agent
699            .install(&scope, &hook_spec("appB", Event::PreToolUse, "b"))
700            .unwrap();
701        let v = read_json(&dir.path().join(".windsurf/hooks.json"));
702        let arr = v["pre_run_command"].as_array().unwrap();
703        assert_eq!(arr.len(), 2);
704    }
705
706    #[test]
707    fn install_mcp_writes_mcp_config_separate_from_hooks() {
708        let dir = tempdir().unwrap();
709        let agent = WindsurfAgent::new();
710        let scope = Scope::Local(dir.path().to_path_buf());
711        let spec = McpSpec::builder("github")
712            .owner("myapp")
713            .stdio("npx", ["@example/server"])
714            .build();
715        agent.install_mcp(&scope, &spec).unwrap();
716        agent
717            .install(&scope, &hook_spec("alpha", Event::PreToolUse, "x"))
718            .unwrap();
719        assert!(dir.path().join(".windsurf/mcp_config.json").exists());
720        assert!(dir.path().join(".windsurf/hooks.json").exists());
721    }
722
723    #[test]
724    fn mcp_supports_global_and_local_scopes() {
725        let agent = WindsurfAgent::new();
726        let scopes = agent.supported_mcp_scopes();
727        assert!(scopes.contains(&ScopeKind::Global));
728        assert!(scopes.contains(&ScopeKind::Local));
729    }
730
731    #[test]
732    fn uninstall_mcp_owner_mismatch_refused() {
733        let dir = tempdir().unwrap();
734        let agent = WindsurfAgent::new();
735        let scope = Scope::Local(dir.path().to_path_buf());
736        let spec = McpSpec::builder("github")
737            .owner("appA")
738            .stdio("npx", ["@example/server"])
739            .build();
740        agent.install_mcp(&scope, &spec).unwrap();
741        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
742        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
743    }
744
745    #[test]
746    fn uninstall_strips_tagged_entry_from_all_event_arrays() {
747        let dir = tempdir().unwrap();
748        let agent = WindsurfAgent::new();
749        let scope = Scope::Local(dir.path().to_path_buf());
750        agent
751            .install(&scope, &hook_spec("alpha", Event::PreToolUse, "a"))
752            .unwrap();
753        agent
754            .install(&scope, &hook_spec("alpha", Event::PostToolUse, "b"))
755            .unwrap();
756        agent.uninstall(&scope, "alpha").unwrap();
757        // After uninstall, the tag must not appear in any event array.
758        let p = dir.path().join(".windsurf/hooks.json");
759        assert!(!agent.is_installed(&scope, "alpha").unwrap());
760        if p.exists() {
761            let v = read_json(&p);
762            for key in known_event_keys() {
763                let Some(arr) = v.get(key).and_then(|x| x.as_array()) else {
764                    continue;
765                };
766                assert!(
767                    !arr.iter().any(|e| e["_agent_config_tag"] == "alpha"),
768                    "tag should be stripped from {key}"
769                );
770            }
771        }
772    }
773
774    #[test]
775    fn uninstall_keeps_other_consumer_entries() {
776        let dir = tempdir().unwrap();
777        let agent = WindsurfAgent::new();
778        let scope = Scope::Local(dir.path().to_path_buf());
779        agent
780            .install(&scope, &hook_spec("appA", Event::PreToolUse, "a"))
781            .unwrap();
782        agent
783            .install(&scope, &hook_spec("appB", Event::PreToolUse, "b"))
784            .unwrap();
785        agent.uninstall(&scope, "appA").unwrap();
786        let v = read_json(&dir.path().join(".windsurf/hooks.json"));
787        let arr = v["pre_run_command"].as_array().unwrap();
788        assert_eq!(arr.len(), 1);
789        assert_eq!(arr[0]["_agent_config_tag"], serde_json::json!("appB"));
790    }
791
792    #[test]
793    fn rejects_global_scope() {
794        let agent = WindsurfAgent::new();
795        let err = agent.is_installed(&Scope::Global, "x").unwrap_err();
796        assert!(matches!(err, AgentConfigError::UnsupportedScope { .. }));
797    }
798
799    #[test]
800    fn install_rules_and_hook_independent() {
801        let dir = tempdir().unwrap();
802        let agent = WindsurfAgent::new();
803        let scope = Scope::Local(dir.path().to_path_buf());
804        agent
805            .install(&scope, &rules_spec("alpha", "rules body"))
806            .unwrap();
807        // No hook file produced when only rules are present.
808        assert!(!dir.path().join(".windsurf/hooks.json").exists());
809        assert!(dir.path().join(".windsurf/rules/alpha.md").exists());
810    }
811
812    fn instruction_spec(name: &str, owner: &str, body: &str) -> InstructionSpec {
813        InstructionSpec::builder(name)
814            .owner(owner)
815            .placement(InstructionPlacement::StandaloneFile)
816            .body(body)
817            .build()
818    }
819
820    #[test]
821    fn instruction_writes_to_rules_dir() {
822        let dir = tempdir().unwrap();
823        let agent = WindsurfAgent::new();
824        let scope = Scope::Local(dir.path().to_path_buf());
825        agent
826            .install_instruction(&scope, &instruction_spec("MYAPP", "myapp", "# Use MyApp\n"))
827            .unwrap();
828        let instr = dir.path().join(".windsurf/rules/MYAPP.md");
829        assert!(instr.exists());
830        assert!(fs::read_to_string(&instr).unwrap().contains("# Use MyApp"));
831    }
832
833    #[test]
834    fn instruction_uninstall_removes_file() {
835        let dir = tempdir().unwrap();
836        let agent = WindsurfAgent::new();
837        let scope = Scope::Local(dir.path().to_path_buf());
838        agent
839            .install_instruction(&scope, &instruction_spec("MYAPP", "myapp", "# Use MyApp\n"))
840            .unwrap();
841        agent
842            .uninstall_instruction(&scope, "MYAPP", "myapp")
843            .unwrap();
844        assert!(!dir.path().join(".windsurf/rules/MYAPP.md").exists());
845    }
846
847    #[test]
848    fn instruction_rejects_global_scope() {
849        let agent = WindsurfAgent::new();
850        let err = agent
851            .install_instruction(
852                &Scope::Global,
853                &instruction_spec("MYAPP", "myapp", "body\n"),
854            )
855            .unwrap_err();
856        assert!(matches!(err, AgentConfigError::UnsupportedScope { .. }));
857    }
858}