1use std::fs;
19use std::path::Path;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct PluginId {
29 pub name: &'static str,
31 pub marketplace: &'static str,
33}
34
35pub const DEVBOY_PLUGIN: PluginId = PluginId {
37 name: "devboy",
38 marketplace: "meteora-devboy",
39};
40
41pub fn is_claude_plugin_enabled(home: &Path, plugin: &PluginId) -> bool {
46 is_plugin_enabled_in(&home.join(".claude").join("settings.json"), plugin)
47}
48
49pub fn is_codex_plugin_enabled(home: &Path, plugin: &PluginId) -> bool {
54 is_plugin_enabled_in(&home.join(".codex").join("settings.json"), plugin)
55}
56
57#[doc(hidden)]
63pub fn is_claude_plugin_installed(home: &Path, plugin_name: &str) -> bool {
64 if plugin_name == DEVBOY_PLUGIN.name {
65 return is_claude_plugin_enabled(home, &DEVBOY_PLUGIN);
66 }
67 false
68}
69
70#[doc(hidden)]
73pub fn is_codex_plugin_installed(home: &Path, plugin_name: &str) -> bool {
74 if plugin_name == DEVBOY_PLUGIN.name {
75 return is_codex_plugin_enabled(home, &DEVBOY_PLUGIN);
76 }
77 false
78}
79
80fn is_plugin_enabled_in(settings_path: &Path, plugin: &PluginId) -> bool {
81 let bytes = match fs::read(settings_path) {
82 Ok(b) => b,
83 Err(_) => return false,
84 };
85 let json: serde_json::Value = match serde_json::from_slice(&bytes) {
86 Ok(v) => v,
87 Err(_) => return false,
88 };
89 let Some(enabled) = json.get("enabledPlugins") else {
90 return false;
91 };
92 enabled_plugins_contains(enabled, plugin)
93}
94
95fn enabled_plugins_contains(value: &serde_json::Value, plugin: &PluginId) -> bool {
104 use serde_json::Value;
105 let qualified = format!("{}@{}", plugin.name, plugin.marketplace);
106 match value {
107 Value::Object(map) => {
108 if let Some(Value::Object(inner)) = map.get(plugin.marketplace)
110 && let Some(v) = inner.get(plugin.name)
111 && is_truthy(v)
112 {
113 return true;
114 }
115 if let Some(v) = map.get(&qualified)
117 && is_truthy(v)
118 {
119 return true;
120 }
121 false
122 }
123 Value::Array(arr) => arr
125 .iter()
126 .any(|v| matches!(v, Value::String(s) if s == &qualified)),
127 _ => false,
128 }
129}
130
131fn is_truthy(value: &serde_json::Value) -> bool {
134 use serde_json::Value;
135 match value {
136 Value::Bool(b) => *b,
137 Value::Object(map) => map
138 .get("enabled")
139 .map(|v| !matches!(v, Value::Bool(false)))
140 .unwrap_or(true),
141 _ => false,
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use std::fs as stdfs;
151 use tempfile::tempdir;
152
153 fn write_settings(home: &Path, agent_dir: &str, body: &str) {
154 let dir = home.join(agent_dir);
155 stdfs::create_dir_all(&dir).unwrap();
156 stdfs::write(dir.join("settings.json"), body).unwrap();
157 }
158
159 #[test]
160 fn missing_file_returns_false() {
161 let home = tempdir().unwrap();
162 assert!(!is_claude_plugin_enabled(home.path(), &DEVBOY_PLUGIN));
163 assert!(!is_codex_plugin_enabled(home.path(), &DEVBOY_PLUGIN));
164 }
165
166 #[test]
167 fn unrelated_settings_returns_false() {
168 let home = tempdir().unwrap();
169 write_settings(home.path(), ".claude", r#"{"theme":"dark"}"#);
170 assert!(!is_claude_plugin_enabled(home.path(), &DEVBOY_PLUGIN));
171 }
172
173 #[test]
174 fn malformed_json_returns_false() {
175 let home = tempdir().unwrap();
176 write_settings(home.path(), ".claude", "not json {{{");
177 assert!(!is_claude_plugin_enabled(home.path(), &DEVBOY_PLUGIN));
178 }
179
180 #[test]
181 fn pattern1_nested_object_marketplace_then_name() {
182 let home = tempdir().unwrap();
183 write_settings(
184 home.path(),
185 ".claude",
186 r#"{
187 "enabledPlugins": {
188 "meteora-devboy": { "devboy": true }
189 }
190 }"#,
191 );
192 assert!(is_claude_plugin_enabled(home.path(), &DEVBOY_PLUGIN));
193 }
194
195 #[test]
196 fn pattern2_qualified_key_at_top_level() {
197 let home = tempdir().unwrap();
198 write_settings(
199 home.path(),
200 ".claude",
201 r#"{ "enabledPlugins": { "devboy@meteora-devboy": true } }"#,
202 );
203 assert!(is_claude_plugin_enabled(home.path(), &DEVBOY_PLUGIN));
204 }
205
206 #[test]
207 fn pattern3_qualified_array_element() {
208 let home = tempdir().unwrap();
209 write_settings(
210 home.path(),
211 ".claude",
212 r#"{ "enabledPlugins": ["other", "devboy@meteora-devboy"] }"#,
213 );
214 assert!(is_claude_plugin_enabled(home.path(), &DEVBOY_PLUGIN));
215 }
216
217 #[test]
224 fn unrelated_plugin_with_devboy_substring_does_not_match() {
225 let home = tempdir().unwrap();
226 write_settings(
228 home.path(),
229 ".claude",
230 r#"{
231 "enabledPlugins": {
232 "third-party": { "devboy-helper": true }
233 }
234 }"#,
235 );
236 assert!(!is_claude_plugin_enabled(home.path(), &DEVBOY_PLUGIN));
237 }
238
239 #[test]
240 fn correct_name_wrong_marketplace_does_not_match() {
241 let home = tempdir().unwrap();
242 write_settings(
243 home.path(),
244 ".claude",
245 r#"{
246 "enabledPlugins": {
247 "fork-marketplace": { "devboy": true }
248 }
249 }"#,
250 );
251 assert!(!is_claude_plugin_enabled(home.path(), &DEVBOY_PLUGIN));
252 }
253
254 #[test]
255 fn explicitly_disabled_plugin_does_not_match() {
256 let home = tempdir().unwrap();
257 write_settings(
258 home.path(),
259 ".claude",
260 r#"{
261 "enabledPlugins": {
262 "meteora-devboy": { "devboy": false }
263 }
264 }"#,
265 );
266 assert!(!is_claude_plugin_enabled(home.path(), &DEVBOY_PLUGIN));
267 }
268
269 #[test]
270 fn explicitly_disabled_via_qualified_key_does_not_match() {
271 let home = tempdir().unwrap();
272 write_settings(
273 home.path(),
274 ".claude",
275 r#"{ "enabledPlugins": { "devboy@meteora-devboy": false } }"#,
276 );
277 assert!(!is_claude_plugin_enabled(home.path(), &DEVBOY_PLUGIN));
278 }
279
280 #[test]
281 fn enabled_object_with_enabled_field_is_truthy() {
282 let home = tempdir().unwrap();
283 write_settings(
284 home.path(),
285 ".claude",
286 r#"{
287 "enabledPlugins": {
288 "meteora-devboy": {
289 "devboy": { "enabled": true, "version": "0.24.0" }
290 }
291 }
292 }"#,
293 );
294 assert!(is_claude_plugin_enabled(home.path(), &DEVBOY_PLUGIN));
295 }
296
297 #[test]
298 fn enabled_object_with_enabled_false_is_skipped() {
299 let home = tempdir().unwrap();
300 write_settings(
301 home.path(),
302 ".claude",
303 r#"{
304 "enabledPlugins": {
305 "meteora-devboy": {
306 "devboy": { "enabled": false }
307 }
308 }
309 }"#,
310 );
311 assert!(!is_claude_plugin_enabled(home.path(), &DEVBOY_PLUGIN));
312 }
313
314 #[test]
315 fn codex_settings_independent_of_claude() {
316 let home = tempdir().unwrap();
317 write_settings(
318 home.path(),
319 ".codex",
320 r#"{ "enabledPlugins": { "devboy@meteora-devboy": true } }"#,
321 );
322 assert!(is_codex_plugin_enabled(home.path(), &DEVBOY_PLUGIN));
323 assert!(!is_claude_plugin_enabled(home.path(), &DEVBOY_PLUGIN));
325 }
326}