Skip to main content

agent_config/agents/cline/
instructions.rs

1//! Cline instructions surface. Standalone files under `.clinerules/`.
2
3use crate::error::AgentConfigError;
4use crate::integration::{InstallReport, InstructionSurface, UninstallReport};
5use crate::plan::{InstallPlan, UninstallPlan};
6use crate::scope::{Scope, ScopeKind};
7use crate::spec::InstructionSpec;
8use crate::status::StatusReport;
9use crate::util::instructions_dir;
10
11use super::{ClineAgent, LEGACY_RULES_DIR, RULES_DIR};
12
13impl ClineAgent {
14    pub(super) fn standalone_layout(
15        &self,
16        scope: &Scope,
17    ) -> Result<instructions_dir::StandaloneLayout, AgentConfigError> {
18        let root = self.project_root(scope)?;
19        let rules_dir = root.join(RULES_DIR);
20        Ok(instructions_dir::StandaloneLayout {
21            config_dir: rules_dir.clone(),
22            instruction_dir: rules_dir,
23        })
24    }
25
26    pub(super) fn legacy_standalone_layout(
27        &self,
28        scope: &Scope,
29    ) -> Result<instructions_dir::StandaloneLayout, AgentConfigError> {
30        let root = self.project_root(scope)?;
31        let rules_dir = root.join(LEGACY_RULES_DIR);
32        Ok(instructions_dir::StandaloneLayout {
33            config_dir: rules_dir.clone(),
34            instruction_dir: rules_dir,
35        })
36    }
37}
38
39impl InstructionSurface for ClineAgent {
40    fn id(&self) -> &'static str {
41        "cline"
42    }
43
44    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
45        &[ScopeKind::Local]
46    }
47
48    fn instruction_status(
49        &self,
50        scope: &Scope,
51        name: &str,
52        expected_owner: &str,
53    ) -> Result<StatusReport, AgentConfigError> {
54        let primary_status = instructions_dir::standalone_status(
55            self.standalone_layout(scope)?,
56            name,
57            expected_owner,
58        )?;
59        if matches!(
60            primary_status.status,
61            crate::status::InstallStatus::InstalledOwned { .. }
62                | crate::status::InstallStatus::InstalledOtherOwner { .. }
63        ) {
64            Ok(primary_status)
65        } else {
66            let legacy_layout = self.legacy_standalone_layout(scope)?;
67            let legacy_status =
68                instructions_dir::standalone_status(legacy_layout, name, expected_owner)?;
69            if matches!(
70                legacy_status.status,
71                crate::status::InstallStatus::InstalledOwned { .. }
72                    | crate::status::InstallStatus::InstalledOtherOwner { .. }
73            ) {
74                Ok(legacy_status)
75            } else {
76                Ok(primary_status)
77            }
78        }
79    }
80
81    fn plan_install_instruction(
82        &self,
83        scope: &Scope,
84        spec: &InstructionSpec,
85    ) -> Result<InstallPlan, AgentConfigError> {
86        instructions_dir::standalone_plan_install(
87            InstructionSurface::id(self),
88            scope,
89            self.standalone_layout(scope),
90            spec,
91        )
92    }
93
94    fn plan_uninstall_instruction(
95        &self,
96        scope: &Scope,
97        name: &str,
98        owner_tag: &str,
99    ) -> Result<UninstallPlan, AgentConfigError> {
100        let mut plan = instructions_dir::standalone_plan_uninstall(
101            InstructionSurface::id(self),
102            scope,
103            self.standalone_layout(scope),
104            name,
105            owner_tag,
106        )?;
107        if let Ok(legacy_layout) = self.legacy_standalone_layout(scope) {
108            let legacy_plan = instructions_dir::standalone_plan_uninstall(
109                InstructionSurface::id(self),
110                scope,
111                Ok(legacy_layout),
112                name,
113                owner_tag,
114            )?;
115            plan.changes.extend(legacy_plan.changes);
116        }
117        Ok(plan)
118    }
119
120    fn install_instruction(
121        &self,
122        scope: &Scope,
123        spec: &InstructionSpec,
124    ) -> Result<InstallReport, AgentConfigError> {
125        instructions_dir::standalone_install(scope, self.standalone_layout(scope)?, spec)
126    }
127
128    fn uninstall_instruction(
129        &self,
130        scope: &Scope,
131        name: &str,
132        owner_tag: &str,
133    ) -> Result<UninstallReport, AgentConfigError> {
134        let mut report = instructions_dir::standalone_uninstall(
135            scope,
136            self.standalone_layout(scope)?,
137            name,
138            owner_tag,
139        )?;
140        if let Ok(legacy_layout) = self.legacy_standalone_layout(scope) {
141            let legacy_report =
142                instructions_dir::standalone_uninstall(scope, legacy_layout, name, owner_tag)?;
143            report.merge(legacy_report);
144        }
145        Ok(report)
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::spec::InstructionPlacement;
153    use tempfile::tempdir;
154
155    fn instruction_spec(name: &str, owner: &str) -> InstructionSpec {
156        InstructionSpec::builder(name)
157            .owner(owner)
158            .placement(InstructionPlacement::StandaloneFile)
159            .body("# Test instruction\n")
160            .build()
161    }
162
163    #[test]
164    fn instruction_writes_to_rules_dir() {
165        let dir = tempdir().unwrap();
166        let agent = ClineAgent::new();
167        let scope = Scope::Local(dir.path().to_path_buf());
168        agent
169            .install_instruction(&scope, &instruction_spec("test-rule", "myapp"))
170            .unwrap();
171        assert!(dir.path().join(".cline/rules/test-rule.md").exists());
172    }
173
174    #[test]
175    fn instruction_uninstall_removes_file() {
176        let dir = tempdir().unwrap();
177        let agent = ClineAgent::new();
178        let scope = Scope::Local(dir.path().to_path_buf());
179        agent
180            .install_instruction(&scope, &instruction_spec("test-rule", "myapp"))
181            .unwrap();
182        agent
183            .uninstall_instruction(&scope, "test-rule", "myapp")
184            .unwrap();
185        assert!(!dir.path().join(".cline/rules/test-rule.md").exists());
186    }
187
188    #[test]
189    fn instruction_rejects_global_scope() {
190        let agent = ClineAgent::new();
191        let spec = instruction_spec("test-rule", "myapp");
192        let plan = agent
193            .plan_install_instruction(&Scope::Global, &spec)
194            .unwrap();
195        assert!(matches!(plan.status, crate::plan::PlanStatus::Refused));
196    }
197}