Skip to main content

agent_config/agents/claude/
instructions.rs

1//! Claude instruction surface. The only `ReferencedFile` placement in the
2//! crate: writes a standalone `<NAME>.md` and injects an `@<NAME>.md` import
3//! line into the host memory file (`CLAUDE.md`).
4
5use std::path::PathBuf;
6
7use crate::error::AgentConfigError;
8use crate::integration::{InstallReport, InstructionSurface, UninstallReport};
9use crate::paths;
10use crate::plan::{InstallPlan, PlanTarget, UninstallPlan};
11use crate::scope::{Scope, ScopeKind};
12use crate::spec::{HookSpec, InstructionSpec};
13use crate::status::StatusReport;
14use crate::util::{fs_atomic, instructions_dir, md_block, ownership};
15
16use super::ClaudeAgent;
17
18impl ClaudeAgent {
19    /// Instruction layout for the given scope.
20    ///
21    /// Returns `(config_dir, host_file, instruction_dir, reference_line)`:
22    /// - `config_dir`: where the ledger lives
23    /// - `host_file`: the file that gets the include reference (`CLAUDE.md`)
24    /// - `instruction_dir`: where instruction files are written
25    /// - `reference_line`: the include reference string (e.g., `@MYAPP.md`)
26    fn instruction_layout(
27        scope: &Scope,
28        name: &str,
29    ) -> Result<(PathBuf, PathBuf, PathBuf, String), AgentConfigError> {
30        Ok(match scope {
31            Scope::Global => {
32                let claude_home = paths::claude_home()?;
33                let host = claude_home.join("CLAUDE.md");
34                let ref_line = format!("@{name}.md");
35                (claude_home.clone(), host, claude_home, ref_line)
36            }
37            Scope::Local(p) => {
38                let config_dir = p.join(".claude");
39                let host = p.join("CLAUDE.md");
40                let instr_dir = config_dir.join("instructions");
41                let ref_line = format!("@.claude/instructions/{name}.md");
42                (config_dir, host, instr_dir, ref_line)
43            }
44        })
45    }
46}
47
48impl InstructionSurface for ClaudeAgent {
49    fn id(&self) -> &'static str {
50        "claude"
51    }
52
53    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
54        &[ScopeKind::Global, ScopeKind::Local]
55    }
56
57    fn instruction_status(
58        &self,
59        scope: &Scope,
60        name: &str,
61        expected_owner: &str,
62    ) -> Result<StatusReport, AgentConfigError> {
63        InstructionSpec::validate_name(name)?;
64        let (config_dir, host_file, instr_dir, _prefix) = Self::instruction_layout(scope, name)?;
65        let led = instructions_dir::ledger_path(&config_dir);
66        let instr_path = instr_dir.join(format!("{name}.md"));
67
68        let instr_exists = instr_path.exists();
69        let block_in_host = if host_file.exists() {
70            let host = fs_atomic::read_to_string_or_empty(&host_file)?;
71            md_block::contains_instruction(&host, name)
72                || md_block::contains_legacy_instruction(&host, name)
73        } else {
74            false
75        };
76
77        let presence = if instr_exists || block_in_host {
78            crate::status::ConfigPresence::Single
79        } else {
80            crate::status::ConfigPresence::Absent
81        };
82
83        let recorded = ownership::owner_of(&led, name)?;
84        Ok(StatusReport::for_instruction(
85            name,
86            instr_path,
87            led,
88            presence,
89            expected_owner,
90            recorded,
91        ))
92    }
93
94    fn plan_install_instruction(
95        &self,
96        scope: &Scope,
97        spec: &InstructionSpec,
98    ) -> Result<InstallPlan, AgentConfigError> {
99        spec.validate()?;
100        let target = PlanTarget::Instruction {
101            integration_id: InstructionSurface::id(self),
102            scope: scope.clone(),
103            name: spec.name.clone(),
104            owner: spec.owner_tag.clone(),
105        };
106        let (config_dir, host_file, instr_dir, ref_line) =
107            Self::instruction_layout(scope, &spec.name)?;
108        let changes = instructions_dir::plan_install(
109            &config_dir,
110            spec,
111            Some(&host_file),
112            Some(&instr_dir),
113            Some(&ref_line),
114        )?;
115        Ok(InstallPlan::from_changes(target, changes))
116    }
117
118    fn plan_uninstall_instruction(
119        &self,
120        scope: &Scope,
121        name: &str,
122        owner_tag: &str,
123    ) -> Result<UninstallPlan, AgentConfigError> {
124        InstructionSpec::validate_name(name)?;
125        HookSpec::validate_tag(owner_tag)?;
126        let target = PlanTarget::Instruction {
127            integration_id: InstructionSurface::id(self),
128            scope: scope.clone(),
129            name: name.to_string(),
130            owner: owner_tag.to_string(),
131        };
132        let (config_dir, host_file, instr_dir, _) = Self::instruction_layout(scope, name)?;
133        let changes = instructions_dir::plan_uninstall(
134            &config_dir,
135            name,
136            owner_tag,
137            Some(&host_file),
138            Some(&instr_dir),
139        )?;
140        Ok(UninstallPlan::from_changes(target, changes))
141    }
142
143    fn install_instruction(
144        &self,
145        scope: &Scope,
146        spec: &InstructionSpec,
147    ) -> Result<InstallReport, AgentConfigError> {
148        spec.validate()?;
149        scope.ensure_contained(&Self::memory_path(scope)?)?;
150        let (config_dir, host_file, instr_dir, ref_line) =
151            Self::instruction_layout(scope, &spec.name)?;
152        scope.ensure_contained(&host_file)?;
153        scope.ensure_contained(&instr_dir.join(&spec.name))?;
154        instructions_dir::install(
155            scope,
156            &config_dir,
157            spec,
158            Some(&host_file),
159            Some(&instr_dir),
160            Some(&ref_line),
161        )
162    }
163
164    fn uninstall_instruction(
165        &self,
166        scope: &Scope,
167        name: &str,
168        owner_tag: &str,
169    ) -> Result<UninstallReport, AgentConfigError> {
170        InstructionSpec::validate_name(name)?;
171        HookSpec::validate_tag(owner_tag)?;
172        let (config_dir, host_file, instr_dir, _) = Self::instruction_layout(scope, name)?;
173        instructions_dir::uninstall(
174            scope,
175            &config_dir,
176            name,
177            owner_tag,
178            Some(&host_file),
179            Some(&instr_dir),
180        )
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::spec::InstructionPlacement;
188    use std::fs;
189    use tempfile::tempdir;
190
191    fn instruction_spec(name: &str, owner: &str) -> InstructionSpec {
192        InstructionSpec::builder(name)
193            .owner(owner)
194            .placement(InstructionPlacement::ReferencedFile)
195            .body("# MyApp\n\nProject-specific guidance.\n")
196            .build()
197    }
198
199    #[test]
200    fn instruction_global_creates_md_and_reference() {
201        let dir = tempdir().unwrap();
202        let claude_home = dir.path().join("claude-home");
203        fs::create_dir_all(&claude_home).unwrap();
204
205        // Temporarily override home by using Local scope with a path that
206        // mimics the global layout. Global scope requires a real home dir,
207        // so we test the layout via Local scope instead.
208        let root = dir.path().join("project");
209        fs::create_dir_all(&root).unwrap();
210
211        let agent = ClaudeAgent::new();
212        let scope = Scope::Local(root.clone());
213        let spec = instruction_spec("MYAPP", "myapp");
214        agent.install_instruction(&scope, &spec).unwrap();
215
216        let instr = root.join(".claude/instructions/MYAPP.md");
217        assert!(instr.exists());
218        assert!(fs::read_to_string(&instr).unwrap().contains("# MyApp"));
219
220        let claude_md = root.join("CLAUDE.md");
221        let content = fs::read_to_string(&claude_md).unwrap();
222        assert!(content.contains("@.claude/instructions/MYAPP.md"));
223        assert!(content.contains("BEGIN AGENT-CONFIG-INSTR:MYAPP"));
224    }
225
226    #[test]
227    fn instruction_idempotent() {
228        let dir = tempdir().unwrap();
229        let root = dir.path().join("project");
230        fs::create_dir_all(&root).unwrap();
231
232        let agent = ClaudeAgent::new();
233        let scope = Scope::Local(root.clone());
234        let spec = instruction_spec("MYAPP", "myapp");
235        agent.install_instruction(&scope, &spec).unwrap();
236        let report = agent.install_instruction(&scope, &spec).unwrap();
237        assert!(report.already_installed);
238    }
239
240    #[test]
241    fn instruction_uninstall_removes_both() {
242        let dir = tempdir().unwrap();
243        let root = dir.path().join("project");
244        fs::create_dir_all(&root).unwrap();
245
246        let agent = ClaudeAgent::new();
247        let scope = Scope::Local(root.clone());
248        let spec = instruction_spec("MYAPP", "myapp");
249        agent.install_instruction(&scope, &spec).unwrap();
250
251        agent
252            .uninstall_instruction(&scope, "MYAPP", "myapp")
253            .unwrap();
254
255        assert!(!root.join(".claude/instructions/MYAPP.md").exists());
256        let claude_md = fs::read_to_string(root.join("CLAUDE.md")).unwrap();
257        assert!(!claude_md.contains("@.claude/instructions/MYAPP.md"));
258    }
259
260    #[test]
261    fn instruction_owner_mismatch_refused() {
262        let dir = tempdir().unwrap();
263        let root = dir.path().join("project");
264        fs::create_dir_all(&root).unwrap();
265
266        let agent = ClaudeAgent::new();
267        let scope = Scope::Local(root.clone());
268        let spec = instruction_spec("MYAPP", "appA");
269        agent.install_instruction(&scope, &spec).unwrap();
270
271        let err = agent
272            .uninstall_instruction(&scope, "MYAPP", "appB")
273            .unwrap_err();
274        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
275    }
276
277    #[test]
278    fn instruction_plan_does_not_mutate() {
279        let dir = tempdir().unwrap();
280        let root = dir.path().join("project");
281        fs::create_dir_all(&root).unwrap();
282
283        let agent = ClaudeAgent::new();
284        let scope = Scope::Local(root.clone());
285        let spec = instruction_spec("MYAPP", "myapp");
286        let plan = agent.plan_install_instruction(&scope, &spec).unwrap();
287
288        assert!(!root.join(".claude/instructions/MYAPP.md").exists());
289        assert!(!root.join("CLAUDE.md").exists());
290        assert!(!plan.changes.is_empty());
291    }
292}