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        let rules = spec
107            .rules
108            .as_ref()
109            .ok_or(AgentConfigError::MissingSpecField {
110                id: "roo",
111                field: "rules",
112            })?;
113        rules_dir::install(scope, RULES_DIR, &spec.tag, &rules.content)
114    }
115
116    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
117        HookSpec::validate_tag(tag)?;
118        let _ = self.require_local(scope)?;
119        rules_dir::uninstall(scope, RULES_DIR, tag)
120    }
121}
122
123impl McpSurface for RooAgent {
124    fn id(&self) -> &'static str {
125        "roo"
126    }
127
128    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
129        &[ScopeKind::Global, ScopeKind::Local]
130    }
131
132    fn mcp_status(
133        &self,
134        scope: &Scope,
135        name: &str,
136        expected_owner: &str,
137    ) -> Result<StatusReport, AgentConfigError> {
138        McpSpec::validate_name(name)?;
139        let cfg = Self::mcp_path(scope)?;
140        let ledger = ownership::mcp_ledger_for(&cfg);
141        let presence = mcp_json_object::config_presence(&cfg, name)?;
142        let recorded = ownership::owner_of(&ledger, name)?;
143        Ok(StatusReport::for_mcp(
144            name,
145            cfg,
146            ledger,
147            presence,
148            expected_owner,
149            recorded,
150        ))
151    }
152
153    fn plan_install_mcp(
154        &self,
155        scope: &Scope,
156        spec: &McpSpec,
157    ) -> Result<InstallPlan, AgentConfigError> {
158        agent_planning::mcp_json_object_install(
159            McpSurface::id(self),
160            scope,
161            spec,
162            Self::mcp_path(scope),
163        )
164    }
165
166    fn plan_uninstall_mcp(
167        &self,
168        scope: &Scope,
169        name: &str,
170        owner_tag: &str,
171    ) -> Result<UninstallPlan, AgentConfigError> {
172        agent_planning::mcp_json_object_uninstall(
173            McpSurface::id(self),
174            scope,
175            name,
176            owner_tag,
177            Self::mcp_path(scope),
178        )
179    }
180
181    fn install_mcp(
182        &self,
183        scope: &Scope,
184        spec: &McpSpec,
185    ) -> Result<InstallReport, AgentConfigError> {
186        spec.validate()?;
187        let cfg = Self::mcp_path(scope)?;
188        spec.validate_local_secret_policy(scope)?;
189        scope.ensure_contained(&cfg)?;
190        let ledger = ownership::mcp_ledger_for(&cfg);
191        mcp_json_object::install(&cfg, &ledger, spec)
192    }
193
194    fn uninstall_mcp(
195        &self,
196        scope: &Scope,
197        name: &str,
198        owner_tag: &str,
199    ) -> Result<UninstallReport, AgentConfigError> {
200        McpSpec::validate_name(name)?;
201        HookSpec::validate_tag(owner_tag)?;
202        let cfg = Self::mcp_path(scope)?;
203        scope.ensure_contained(&cfg)?;
204        let ledger = ownership::mcp_ledger_for(&cfg);
205        mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
206    }
207}
208
209impl RooAgent {
210    fn standalone_layout(
211        &self,
212        scope: &Scope,
213    ) -> Result<instructions_dir::StandaloneLayout, AgentConfigError> {
214        let root = self.require_local(scope)?;
215        Ok(instructions_dir::StandaloneLayout {
216            config_dir: root.join(".roo"),
217            instruction_dir: root.join(".roo/rules"),
218        })
219    }
220}
221
222impl InstructionSurface for RooAgent {
223    fn id(&self) -> &'static str {
224        "roo"
225    }
226
227    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
228        &[ScopeKind::Local]
229    }
230
231    fn instruction_status(
232        &self,
233        scope: &Scope,
234        name: &str,
235        expected_owner: &str,
236    ) -> Result<StatusReport, AgentConfigError> {
237        instructions_dir::standalone_status(self.standalone_layout(scope)?, name, expected_owner)
238    }
239
240    fn plan_install_instruction(
241        &self,
242        scope: &Scope,
243        spec: &InstructionSpec,
244    ) -> Result<InstallPlan, AgentConfigError> {
245        instructions_dir::standalone_plan_install(
246            InstructionSurface::id(self),
247            scope,
248            self.standalone_layout(scope),
249            spec,
250        )
251    }
252
253    fn plan_uninstall_instruction(
254        &self,
255        scope: &Scope,
256        name: &str,
257        owner_tag: &str,
258    ) -> Result<UninstallPlan, AgentConfigError> {
259        instructions_dir::standalone_plan_uninstall(
260            InstructionSurface::id(self),
261            scope,
262            self.standalone_layout(scope),
263            name,
264            owner_tag,
265        )
266    }
267
268    fn install_instruction(
269        &self,
270        scope: &Scope,
271        spec: &InstructionSpec,
272    ) -> Result<InstallReport, AgentConfigError> {
273        instructions_dir::standalone_install(scope, self.standalone_layout(scope)?, spec)
274    }
275
276    fn uninstall_instruction(
277        &self,
278        scope: &Scope,
279        name: &str,
280        owner_tag: &str,
281    ) -> Result<UninstallReport, AgentConfigError> {
282        instructions_dir::standalone_uninstall(
283            scope,
284            self.standalone_layout(scope)?,
285            name,
286            owner_tag,
287        )
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use serde_json::{json, Value};
295    use tempfile::tempdir;
296
297    fn rules_spec(tag: &str, body: &str) -> HookSpec {
298        HookSpec::builder(tag)
299            .command_program("noop", [] as [&str; 0])
300            .rules(body)
301            .build()
302    }
303
304    fn mcp_spec(name: &str, owner: &str) -> McpSpec {
305        McpSpec::builder(name)
306            .owner(owner)
307            .stdio("npx", ["-y", "@example/server"])
308            .build()
309    }
310
311    fn read_json(p: &Path) -> Value {
312        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
313    }
314
315    #[test]
316    fn install_rules_writes_dot_roo_rules() {
317        let dir = tempdir().unwrap();
318        let agent = RooAgent::new();
319        let scope = Scope::Local(dir.path().to_path_buf());
320        agent.install(&scope, &rules_spec("alpha", "body")).unwrap();
321        assert!(dir.path().join(".roo/rules/alpha.md").exists());
322    }
323
324    #[test]
325    fn install_mcp_writes_project_mcp_json() {
326        let dir = tempdir().unwrap();
327        let agent = RooAgent::new();
328        let scope = Scope::Local(dir.path().to_path_buf());
329        agent
330            .install_mcp(&scope, &mcp_spec("github", "myapp"))
331            .unwrap();
332        let cfg = dir.path().join(".roo/mcp.json");
333        let v = read_json(&cfg);
334        assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
335    }
336
337    #[test]
338    fn install_mcp_idempotent() {
339        let dir = tempdir().unwrap();
340        let agent = RooAgent::new();
341        let scope = Scope::Local(dir.path().to_path_buf());
342        let s = mcp_spec("github", "myapp");
343        agent.install_mcp(&scope, &s).unwrap();
344        let r = agent.install_mcp(&scope, &s).unwrap();
345        assert!(r.already_installed);
346    }
347
348    #[test]
349    fn uninstall_mcp_owner_mismatch_refused() {
350        let dir = tempdir().unwrap();
351        let agent = RooAgent::new();
352        let scope = Scope::Local(dir.path().to_path_buf());
353        agent
354            .install_mcp(&scope, &mcp_spec("github", "appA"))
355            .unwrap();
356        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
357        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
358    }
359}
360
361#[cfg(test)]
362mod instruction_tests {
363    use super::*;
364    use crate::integration::InstructionSurface;
365    use crate::spec::InstructionPlacement;
366    use tempfile::tempdir;
367
368    fn instruction_spec(name: &str, owner: &str) -> InstructionSpec {
369        InstructionSpec::builder(name)
370            .owner(owner)
371            .placement(InstructionPlacement::StandaloneFile)
372            .body("# Test instruction\n")
373            .build()
374    }
375
376    #[test]
377    fn instruction_writes_to_rules_dir() {
378        let dir = tempdir().unwrap();
379        let agent = RooAgent::new();
380        let scope = Scope::Local(dir.path().to_path_buf());
381        agent
382            .install_instruction(&scope, &instruction_spec("test-rule", "myapp"))
383            .unwrap();
384        assert!(dir.path().join(".roo/rules/test-rule.md").exists());
385    }
386
387    #[test]
388    fn instruction_uninstall_removes_file() {
389        let dir = tempdir().unwrap();
390        let agent = RooAgent::new();
391        let scope = Scope::Local(dir.path().to_path_buf());
392        agent
393            .install_instruction(&scope, &instruction_spec("test-rule", "myapp"))
394            .unwrap();
395        agent
396            .uninstall_instruction(&scope, "test-rule", "myapp")
397            .unwrap();
398        assert!(!dir.path().join(".roo/rules/test-rule.md").exists());
399    }
400
401    #[test]
402    fn instruction_rejects_global_scope() {
403        let agent = RooAgent::new();
404        let spec = instruction_spec("test-rule", "myapp");
405        let plan = agent
406            .plan_install_instruction(&Scope::Global, &spec)
407            .expect("plan should refuse, not error");
408        assert_eq!(plan.status, crate::plan::PlanStatus::Refused);
409    }
410}