Skip to main content

agent_config/agents/
roo.rs

1//! Roo Code integration.
2//!
3//! Two surfaces:
4//!
5//! 1. **Rules** — project-local markdown files at `.roo/rules/<tag>.md`.
6//!
7//! 2. **MCP servers** — global VS Code extension config at
8//!    `Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json`
9//!    or project config at `.roo/mcp.json`, keyed by server name under
10//!    `mcpServers`.
11
12use std::path::Path;
13use std::path::PathBuf;
14
15use crate::agents::planning as agent_planning;
16use crate::error::AgentConfigError;
17use crate::integration::{
18    InstallReport, InstructionSurface, Integration, McpSurface, UninstallReport,
19};
20use crate::paths;
21use crate::plan::{InstallPlan, UninstallPlan};
22use crate::scope::{Scope, ScopeKind};
23use crate::spec::{HookSpec, InstructionSpec, McpSpec};
24use crate::status::StatusReport;
25use crate::util::{instructions_dir, mcp_json_object, ownership, rules_dir};
26
27const RULES_DIR: &str = ".roo/rules";
28
29/// Roo Code integration.
30#[derive(Debug, Clone, Copy, Default)]
31pub struct RooAgent {
32    _private: (),
33}
34
35impl RooAgent {
36    /// Construct an instance. Stateless.
37    pub const fn new() -> Self {
38        Self { _private: () }
39    }
40
41    fn require_local<'a>(&self, scope: &'a Scope) -> Result<&'a Path, AgentConfigError> {
42        match scope {
43            Scope::Local(p) => Ok(p),
44            Scope::Global => Err(AgentConfigError::UnsupportedScope {
45                id: "roo",
46                scope: ScopeKind::Global,
47            }),
48        }
49    }
50
51    fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
52        Ok(match scope {
53            Scope::Global => paths::roo_mcp_global_file()?,
54            Scope::Local(p) => p.join(".roo").join("mcp.json"),
55        })
56    }
57}
58
59impl Integration for RooAgent {
60    fn id(&self) -> &'static str {
61        "roo"
62    }
63
64    fn display_name(&self) -> &'static str {
65        "Roo Code"
66    }
67
68    fn supported_scopes(&self) -> &'static [ScopeKind] {
69        &[ScopeKind::Local]
70    }
71
72    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
73        HookSpec::validate_tag(tag)?;
74        let root = self.require_local(scope)?;
75        let path = rules_dir::target_path(root, RULES_DIR, tag);
76        Ok(StatusReport::for_file_hook(tag, path))
77    }
78
79    fn plan_install(
80        &self,
81        scope: &Scope,
82        spec: &HookSpec,
83    ) -> Result<InstallPlan, AgentConfigError> {
84        agent_planning::rules_install(
85            Integration::id(self),
86            scope,
87            spec,
88            self.require_local(scope),
89            RULES_DIR,
90        )
91    }
92
93    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
94        agent_planning::rules_uninstall(
95            Integration::id(self),
96            scope,
97            tag,
98            self.require_local(scope),
99            RULES_DIR,
100        )
101    }
102
103    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
104        HookSpec::validate_tag(&spec.tag)?;
105        let _ = self.require_local(scope)?;
106        agent_planning::validate_prompt_only_event(Integration::id(self), &spec.event)?;
107        let rules = spec
108            .rules
109            .as_ref()
110            .ok_or(AgentConfigError::MissingSpecField {
111                id: "roo",
112                field: "rules",
113            })?;
114        rules_dir::install(scope, RULES_DIR, &spec.tag, &rules.content)
115    }
116
117    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
118        HookSpec::validate_tag(tag)?;
119        let _ = self.require_local(scope)?;
120        rules_dir::uninstall(scope, RULES_DIR, tag)
121    }
122}
123
124impl McpSurface for RooAgent {
125    fn id(&self) -> &'static str {
126        "roo"
127    }
128
129    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
130        &[ScopeKind::Global, ScopeKind::Local]
131    }
132
133    fn mcp_status(
134        &self,
135        scope: &Scope,
136        name: &str,
137        expected_owner: &str,
138    ) -> Result<StatusReport, AgentConfigError> {
139        McpSpec::validate_name(name)?;
140        let cfg = Self::mcp_path(scope)?;
141        let ledger = ownership::mcp_ledger_for(&cfg);
142        let presence = mcp_json_object::config_presence(&cfg, name)?;
143        let recorded = ownership::owner_of(&ledger, name)?;
144        Ok(StatusReport::for_mcp(
145            name,
146            cfg,
147            ledger,
148            presence,
149            expected_owner,
150            recorded,
151        ))
152    }
153
154    fn plan_install_mcp(
155        &self,
156        scope: &Scope,
157        spec: &McpSpec,
158    ) -> Result<InstallPlan, AgentConfigError> {
159        agent_planning::mcp_json_object_install(
160            McpSurface::id(self),
161            scope,
162            spec,
163            Self::mcp_path(scope),
164        )
165    }
166
167    fn plan_uninstall_mcp(
168        &self,
169        scope: &Scope,
170        name: &str,
171        owner_tag: &str,
172    ) -> Result<UninstallPlan, AgentConfigError> {
173        agent_planning::mcp_json_object_uninstall(
174            McpSurface::id(self),
175            scope,
176            name,
177            owner_tag,
178            Self::mcp_path(scope),
179        )
180    }
181
182    fn install_mcp(
183        &self,
184        scope: &Scope,
185        spec: &McpSpec,
186    ) -> Result<InstallReport, AgentConfigError> {
187        spec.validate()?;
188        let cfg = Self::mcp_path(scope)?;
189        spec.validate_local_secret_policy(scope)?;
190        scope.ensure_contained(&cfg)?;
191        let ledger = ownership::mcp_ledger_for(&cfg);
192        mcp_json_object::install(&cfg, &ledger, spec)
193    }
194
195    fn uninstall_mcp(
196        &self,
197        scope: &Scope,
198        name: &str,
199        owner_tag: &str,
200    ) -> Result<UninstallReport, AgentConfigError> {
201        McpSpec::validate_name(name)?;
202        HookSpec::validate_tag(owner_tag)?;
203        let cfg = Self::mcp_path(scope)?;
204        scope.ensure_contained(&cfg)?;
205        let ledger = ownership::mcp_ledger_for(&cfg);
206        mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
207    }
208}
209
210impl RooAgent {
211    fn standalone_layout(
212        &self,
213        scope: &Scope,
214    ) -> Result<instructions_dir::StandaloneLayout, AgentConfigError> {
215        let root = self.require_local(scope)?;
216        Ok(instructions_dir::StandaloneLayout {
217            config_dir: root.join(".roo"),
218            instruction_dir: root.join(".roo/rules"),
219        })
220    }
221}
222
223impl InstructionSurface for RooAgent {
224    fn id(&self) -> &'static str {
225        "roo"
226    }
227
228    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
229        &[ScopeKind::Local]
230    }
231
232    fn instruction_status(
233        &self,
234        scope: &Scope,
235        name: &str,
236        expected_owner: &str,
237    ) -> Result<StatusReport, AgentConfigError> {
238        instructions_dir::standalone_status(self.standalone_layout(scope)?, name, expected_owner)
239    }
240
241    fn plan_install_instruction(
242        &self,
243        scope: &Scope,
244        spec: &InstructionSpec,
245    ) -> Result<InstallPlan, AgentConfigError> {
246        instructions_dir::standalone_plan_install(
247            InstructionSurface::id(self),
248            scope,
249            self.standalone_layout(scope),
250            spec,
251        )
252    }
253
254    fn plan_uninstall_instruction(
255        &self,
256        scope: &Scope,
257        name: &str,
258        owner_tag: &str,
259    ) -> Result<UninstallPlan, AgentConfigError> {
260        instructions_dir::standalone_plan_uninstall(
261            InstructionSurface::id(self),
262            scope,
263            self.standalone_layout(scope),
264            name,
265            owner_tag,
266        )
267    }
268
269    fn install_instruction(
270        &self,
271        scope: &Scope,
272        spec: &InstructionSpec,
273    ) -> Result<InstallReport, AgentConfigError> {
274        instructions_dir::standalone_install(scope, self.standalone_layout(scope)?, spec)
275    }
276
277    fn uninstall_instruction(
278        &self,
279        scope: &Scope,
280        name: &str,
281        owner_tag: &str,
282    ) -> Result<UninstallReport, AgentConfigError> {
283        instructions_dir::standalone_uninstall(
284            scope,
285            self.standalone_layout(scope)?,
286            name,
287            owner_tag,
288        )
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use serde_json::{json, Value};
296    use tempfile::tempdir;
297
298    fn rules_spec(tag: &str, body: &str) -> HookSpec {
299        HookSpec::builder(tag)
300            .command_program("noop", [] as [&str; 0])
301            .rules(body)
302            .build()
303    }
304
305    fn mcp_spec(name: &str, owner: &str) -> McpSpec {
306        McpSpec::builder(name)
307            .owner(owner)
308            .stdio("npx", ["-y", "@example/server"])
309            .build()
310    }
311
312    fn read_json(p: &Path) -> Value {
313        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
314    }
315
316    #[test]
317    fn install_rules_writes_dot_roo_rules() {
318        let dir = tempdir().unwrap();
319        let agent = RooAgent::new();
320        let scope = Scope::Local(dir.path().to_path_buf());
321        agent.install(&scope, &rules_spec("alpha", "body")).unwrap();
322        assert!(dir.path().join(".roo/rules/alpha.md").exists());
323    }
324
325    #[test]
326    fn install_mcp_writes_project_mcp_json() {
327        let dir = tempdir().unwrap();
328        let agent = RooAgent::new();
329        let scope = Scope::Local(dir.path().to_path_buf());
330        agent
331            .install_mcp(&scope, &mcp_spec("github", "myapp"))
332            .unwrap();
333        let cfg = dir.path().join(".roo/mcp.json");
334        let v = read_json(&cfg);
335        assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
336    }
337
338    #[test]
339    fn install_mcp_idempotent() {
340        let dir = tempdir().unwrap();
341        let agent = RooAgent::new();
342        let scope = Scope::Local(dir.path().to_path_buf());
343        let s = mcp_spec("github", "myapp");
344        agent.install_mcp(&scope, &s).unwrap();
345        let r = agent.install_mcp(&scope, &s).unwrap();
346        assert!(r.already_installed);
347    }
348
349    #[test]
350    fn uninstall_mcp_owner_mismatch_refused() {
351        let dir = tempdir().unwrap();
352        let agent = RooAgent::new();
353        let scope = Scope::Local(dir.path().to_path_buf());
354        agent
355            .install_mcp(&scope, &mcp_spec("github", "appA"))
356            .unwrap();
357        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
358        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
359    }
360}
361
362#[cfg(test)]
363mod instruction_tests {
364    use super::*;
365    use crate::integration::InstructionSurface;
366    use crate::spec::InstructionPlacement;
367    use tempfile::tempdir;
368
369    fn instruction_spec(name: &str, owner: &str) -> InstructionSpec {
370        InstructionSpec::builder(name)
371            .owner(owner)
372            .placement(InstructionPlacement::StandaloneFile)
373            .body("# Test instruction\n")
374            .build()
375    }
376
377    #[test]
378    fn instruction_writes_to_rules_dir() {
379        let dir = tempdir().unwrap();
380        let agent = RooAgent::new();
381        let scope = Scope::Local(dir.path().to_path_buf());
382        agent
383            .install_instruction(&scope, &instruction_spec("test-rule", "myapp"))
384            .unwrap();
385        assert!(dir.path().join(".roo/rules/test-rule.md").exists());
386    }
387
388    #[test]
389    fn instruction_uninstall_removes_file() {
390        let dir = tempdir().unwrap();
391        let agent = RooAgent::new();
392        let scope = Scope::Local(dir.path().to_path_buf());
393        agent
394            .install_instruction(&scope, &instruction_spec("test-rule", "myapp"))
395            .unwrap();
396        agent
397            .uninstall_instruction(&scope, "test-rule", "myapp")
398            .unwrap();
399        assert!(!dir.path().join(".roo/rules/test-rule.md").exists());
400    }
401
402    #[test]
403    fn instruction_rejects_global_scope() {
404        let agent = RooAgent::new();
405        let spec = instruction_spec("test-rule", "myapp");
406        let plan = agent
407            .plan_install_instruction(&Scope::Global, &spec)
408            .expect("plan should refuse, not error");
409        assert_eq!(plan.status, crate::plan::PlanStatus::Refused);
410    }
411}