agent_config/agents/claude/
instructions.rs1use 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 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 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}