eval_magic/adapters/claude_code/
plugin_shadow.rs1use serde::Deserialize;
13use serde::de::DeserializeOwned;
14use std::collections::{HashMap, HashSet};
15use std::fs;
16use std::path::{Path, PathBuf};
17
18use crate::adapters::skill_shadow::{PluginShadowReport, ShadowSource};
19
20pub fn resolve_config_dir(config_dir_override: Option<&str>) -> PathBuf {
23 match config_dir_override {
24 Some(o) if !o.trim().is_empty() => PathBuf::from(o),
25 _ => std::env::home_dir().unwrap_or_default().join(".claude"),
26 }
27}
28
29pub fn config_dir_from_env() -> PathBuf {
34 resolve_config_dir(std::env::var("CLAUDE_CONFIG_DIR").ok().as_deref())
35}
36
37fn read_json_safe<T: DeserializeOwned>(path: &Path) -> Option<T> {
38 let raw = fs::read_to_string(path).ok()?;
39 serde_json::from_str(&raw).ok()
40}
41
42#[derive(Debug, Deserialize)]
43struct Settings {
44 #[serde(rename = "enabledPlugins")]
45 enabled_plugins: Option<HashMap<String, bool>>,
46}
47
48fn resolve_enabled_plugins(config_dir: &Path, cwd: &Path) -> HashMap<String, bool> {
52 let sources = [
53 config_dir.join("settings.json"),
54 cwd.join(".claude").join("settings.json"),
55 cwd.join(".claude").join("settings.local.json"),
56 ];
57 let mut merged = HashMap::new();
58 for path in sources {
59 if let Some(s) = read_json_safe::<Settings>(&path)
60 && let Some(ep) = s.enabled_plugins
61 {
62 merged.extend(ep);
63 }
64 }
65 merged
66}
67
68fn skill_folder_names(dir: &Path) -> Vec<(String, PathBuf)> {
70 let mut out = Vec::new();
71 let Ok(entries) = fs::read_dir(dir) else {
72 return out;
73 };
74 for entry in entries.flatten() {
75 let path = entry.path();
76 if !path.is_dir() {
77 continue;
78 }
79 if path.join("SKILL.md").exists() {
80 out.push((entry.file_name().to_string_lossy().into_owned(), path));
81 }
82 }
83 out
84}
85
86#[derive(Debug, Deserialize)]
87struct Install {
88 #[serde(rename = "installPath")]
89 install_path: Option<String>,
90}
91
92#[derive(Debug, Deserialize)]
93struct InstalledPlugins {
94 plugins: Option<HashMap<String, Vec<Install>>>,
95}
96
97fn enabled_plugin_skills(config_dir: &Path, enabled: &HashMap<String, bool>) -> Vec<ShadowSource> {
99 let mut out = Vec::new();
100 let manifest: Option<InstalledPlugins> =
101 read_json_safe(&config_dir.join("plugins").join("installed_plugins.json"));
102 let Some(plugins) = manifest.and_then(|m| m.plugins) else {
103 return out;
104 };
105 for (key, installs) in plugins {
106 if enabled.get(&key) != Some(&true) {
107 continue; }
109 for inst in installs {
110 let Some(install_path) = inst.install_path else {
111 continue;
112 };
113 for (name, path) in skill_folder_names(&Path::new(&install_path).join("skills")) {
114 out.push(ShadowSource::Plugin {
115 plugin: key.clone(),
116 skill_name: name,
117 path: path.to_string_lossy().into_owned(),
118 });
119 }
120 }
121 }
122 out
123}
124
125fn global_skills(config_dir: &Path) -> Vec<ShadowSource> {
127 skill_folder_names(&config_dir.join("skills"))
128 .into_iter()
129 .map(|(name, path)| ShadowSource::GlobalSkill {
130 skill_name: name,
131 path: path.to_string_lossy().into_owned(),
132 })
133 .collect()
134}
135
136pub fn detect_plugin_shadows(
139 config_dir: &Path,
140 cwd: &Path,
141 staged_skill_names: &[&str],
142) -> PluginShadowReport {
143 let staged: HashSet<&str> = staged_skill_names.iter().copied().collect();
144 let enabled = resolve_enabled_plugins(config_dir, cwd);
145 let mut shadowed = Vec::new();
146
147 for s in enabled_plugin_skills(config_dir, &enabled) {
148 if staged.contains(s.skill_name()) {
149 shadowed.push(s);
150 }
151 }
152 for s in global_skills(config_dir) {
153 if staged.contains(s.skill_name()) {
154 shadowed.push(s);
155 }
156 }
157
158 PluginShadowReport {
159 config_dir: config_dir.to_string_lossy().into_owned(),
160 shadowed,
161 }
162}
163
164pub fn shadow_preflight(
168 config_dir: &Path,
169 cwd: &Path,
170 staged_skill_names: &[&str],
171) -> Option<PluginShadowReport> {
172 let report = detect_plugin_shadows(config_dir, cwd, staged_skill_names);
173 (!report.shadowed.is_empty()).then_some(report)
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use serde_json::json;
180 use std::fs;
181 use std::path::{Path, PathBuf};
182 use tempfile::TempDir;
183
184 fn fresh(dir: &TempDir) -> (PathBuf, PathBuf) {
185 let config = dir.path().join("config");
186 let cwd = dir.path().join("cwd");
187 fs::create_dir_all(&config).unwrap();
188 fs::create_dir_all(&cwd).unwrap();
189 (config, cwd)
190 }
191
192 fn write_file(path: &Path, body: &str) {
193 fs::create_dir_all(path.parent().unwrap()).unwrap();
194 fs::write(path, body).unwrap();
195 }
196
197 fn install_plugin(config: &Path, key: &str, skill_names: &[&str]) -> PathBuf {
198 let install = config
199 .join("plugins")
200 .join("cache")
201 .join(key.replace('@', "__"));
202 for name in skill_names {
203 write_file(
204 &install.join("skills").join(name).join("SKILL.md"),
205 &format!("---\nname: {name}\ndescription: x\n---\n"),
206 );
207 }
208 install
209 }
210
211 fn write_installed_manifest(config: &Path, entries: &[(&str, &Path)]) {
212 let mut plugins = serde_json::Map::new();
213 for (key, install) in entries {
214 plugins.insert(
215 (*key).to_string(),
216 json!([{"installPath": install.to_string_lossy()}]),
217 );
218 }
219 write_file(
220 &config.join("plugins").join("installed_plugins.json"),
221 &serde_json::to_string_pretty(&json!({"version": 2, "plugins": plugins})).unwrap(),
222 );
223 }
224
225 fn write_settings(path: &Path, enabled: &[(&str, bool)]) {
226 let mut m = serde_json::Map::new();
227 for (k, v) in enabled {
228 m.insert((*k).to_string(), json!(v));
229 }
230 write_file(
231 path,
232 &serde_json::to_string(&json!({"enabledPlugins": m})).unwrap(),
233 );
234 }
235
236 #[test]
237 fn honors_config_dir_override() {
238 assert_eq!(
239 resolve_config_dir(Some("/custom/cfg")),
240 PathBuf::from("/custom/cfg")
241 );
242 }
243
244 #[test]
245 fn defaults_to_home_claude_when_unset() {
246 let expected = std::env::home_dir().unwrap_or_default().join(".claude");
247 assert_eq!(resolve_config_dir(None), expected);
248 assert_eq!(resolve_config_dir(Some(" ")), expected);
250 }
251
252 #[test]
253 fn flags_skill_also_provided_by_enabled_plugin() {
254 let tmp = TempDir::new().unwrap();
255 let (config, cwd) = fresh(&tmp);
256 let ip = install_plugin(
257 &config,
258 "slow-powers@slowdini",
259 &["verification-before-completion", "writing-skills"],
260 );
261 write_installed_manifest(&config, &[("slow-powers@slowdini", &ip)]);
262 write_settings(
263 &config.join("settings.json"),
264 &[("slow-powers@slowdini", true)],
265 );
266
267 let report = detect_plugin_shadows(&config, &cwd, &["verification-before-completion"]);
268 assert_eq!(report.shadowed.len(), 1);
269 match &report.shadowed[0] {
270 ShadowSource::Plugin {
271 plugin, skill_name, ..
272 } => {
273 assert_eq!(plugin, "slow-powers@slowdini");
274 assert_eq!(skill_name, "verification-before-completion");
275 }
276 other => panic!("expected plugin shadow, got {other:?}"),
277 }
278 }
279
280 #[test]
281 fn does_not_flag_disabled_plugin() {
282 let tmp = TempDir::new().unwrap();
283 let (config, cwd) = fresh(&tmp);
284 let ip = install_plugin(
285 &config,
286 "slow-powers@slowdini",
287 &["verification-before-completion"],
288 );
289 write_installed_manifest(&config, &[("slow-powers@slowdini", &ip)]);
290 write_settings(
291 &config.join("settings.json"),
292 &[("slow-powers@slowdini", false)],
293 );
294
295 let report = detect_plugin_shadows(&config, &cwd, &["verification-before-completion"]);
296 assert_eq!(report.shadowed.len(), 0);
297 }
298
299 #[test]
300 fn project_settings_disabling_user_enabled_plugin_suppresses_shadow() {
301 let tmp = TempDir::new().unwrap();
302 let (config, cwd) = fresh(&tmp);
303 let ip = install_plugin(
304 &config,
305 "slow-powers@slowdini",
306 &["verification-before-completion"],
307 );
308 write_installed_manifest(&config, &[("slow-powers@slowdini", &ip)]);
309 write_settings(
310 &config.join("settings.json"),
311 &[("slow-powers@slowdini", true)],
312 );
313 write_settings(
315 &cwd.join(".claude").join("settings.json"),
316 &[("slow-powers@slowdini", false)],
317 );
318
319 let report = detect_plugin_shadows(&config, &cwd, &["verification-before-completion"]);
320 assert_eq!(report.shadowed.len(), 0);
321 }
322
323 #[test]
324 fn flags_skill_also_in_global_skills_dir() {
325 let tmp = TempDir::new().unwrap();
326 let (config, cwd) = fresh(&tmp);
327 write_file(
328 &config.join("skills").join("my-skill").join("SKILL.md"),
329 "---\nname: my-skill\n---\n",
330 );
331
332 let report = detect_plugin_shadows(&config, &cwd, &["my-skill"]);
333 assert_eq!(report.shadowed.len(), 1);
334 match &report.shadowed[0] {
335 ShadowSource::GlobalSkill { skill_name, .. } => assert_eq!(skill_name, "my-skill"),
336 other => panic!("expected global-skill shadow, got {other:?}"),
337 }
338 }
339
340 #[test]
341 fn no_shadow_when_staged_names_match_nothing() {
342 let tmp = TempDir::new().unwrap();
343 let (config, cwd) = fresh(&tmp);
344 let ip = install_plugin(&config, "p@m", &["other"]);
345 write_installed_manifest(&config, &[("p@m", &ip)]);
346 write_settings(&config.join("settings.json"), &[("p@m", true)]);
347
348 let report = detect_plugin_shadows(&config, &cwd, &["mine"]);
349 assert_eq!(report.shadowed.len(), 0);
350 }
351
352 #[test]
353 fn graceful_when_config_dir_has_no_plugins_or_skills() {
354 let tmp = TempDir::new().unwrap();
355 let (config, cwd) = fresh(&tmp);
356 let report = detect_plugin_shadows(&config, &cwd, &["x"]);
357 assert_eq!(report.shadowed.len(), 0);
358 assert_eq!(report.config_dir, config.to_string_lossy());
359 }
360
361 #[test]
362 fn preflight_is_none_when_nothing_is_shadowed() {
363 let tmp = TempDir::new().unwrap();
364 let (config, cwd) = fresh(&tmp);
365 assert_eq!(shadow_preflight(&config, &cwd, &["mine"]), None);
366 }
367
368 #[test]
369 fn preflight_returns_the_report_when_a_skill_is_shadowed() {
370 let tmp = TempDir::new().unwrap();
371 let (config, cwd) = fresh(&tmp);
372 write_file(
373 &config.join("skills").join("my-skill").join("SKILL.md"),
374 "---\nname: my-skill\n---\n",
375 );
376 let report = shadow_preflight(&config, &cwd, &["my-skill"]).expect("skill is shadowed");
377 assert_eq!(report.shadowed.len(), 1);
378 }
379}