Skip to main content

devboy_skills/
plugin_dedup.rs

1//! Detect when a Claude Code / Codex plugin already provides our skills,
2//! so `devboy onboard` can skip re-installing into the same agent dir.
3//!
4//! ADR-018 §5: when the user runs `/plugin install devboy@meteora-devboy`,
5//! Claude Code records the plugin in `~/.claude/settings.json#enabledPlugins`.
6//! At that point the plugin's `skills/` directory is already on the agent's
7//! search path; another copy of `~/.claude/skills/devboy-*` would just
8//! shadow the namespaced versions. We detect the marker and skip the
9//! Claude (or Codex) install target entirely.
10//!
11//! Detection is **exact** — we match the plugin id against three concrete
12//! `enabledPlugins` shapes that Claude Code and Codex CLI have produced
13//! across their releases. A loose substring scan would be unsafe: a false
14//! positive here causes `devboy onboard` to skip skill installation, which
15//! would leave the user without skills (the plugin we thought was loaded
16//! turns out not to be ours).
17
18use std::fs;
19use std::path::Path;
20
21/// A plugin's identity inside a marketplace.
22///
23/// In Claude Code's user settings, the qualified id is
24/// `"<name>@<marketplace>"` (e.g. `devboy@meteora-devboy`). Some agent
25/// versions also serialise plugins as `{ "<marketplace>": { "<name>":
26/// true } }`. Both shapes are matched exactly.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct PluginId {
29    /// Plugin name (matches `plugin.json#name`).
30    pub name: &'static str,
31    /// Owning marketplace name (matches `marketplace.json#name`).
32    pub marketplace: &'static str,
33}
34
35/// The DevBoy plugin identity. Used by the onboard dedup logic.
36pub const DEVBOY_PLUGIN: PluginId = PluginId {
37    name: "devboy",
38    marketplace: "meteora-devboy",
39};
40
41/// `true` if `~/.claude/settings.json` enables the given plugin.
42///
43/// Returns `false` when the file is missing, unreadable, contains no
44/// `enabledPlugins` section, or lists only other plugins.
45pub fn is_claude_plugin_enabled(home: &Path, plugin: &PluginId) -> bool {
46    is_plugin_enabled_in(&home.join(".claude").join("settings.json"), plugin)
47}
48
49/// `true` if `~/.codex/settings.json` enables the given plugin.
50///
51/// Codex CLI's settings schema is younger than Claude Code's; we apply
52/// the same exact-match rules. Returns `false` for missing or empty files.
53pub 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// Backwards-compatible thin wrappers for callers that haven't migrated to
58// the typed API yet (e.g. ad-hoc scripts).
59
60/// Convenience wrapper: check by qualified id, defaulting to the
61/// `meteora-devboy` marketplace.
62#[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/// Convenience wrapper: check by qualified id, defaulting to the
71/// `meteora-devboy` marketplace.
72#[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
95/// Match the plugin against the three known `enabledPlugins` shapes:
96///
97/// 1. Nested: `{ "<marketplace>": { "<name>": true } }`
98/// 2. Qualified key: `{ "<name>@<marketplace>": true }`
99/// 3. Qualified array element: `["<name>@<marketplace>"]`
100///
101/// Anything else (substring matches, partial keys, mismatched
102/// marketplace) is rejected.
103fn 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            // Pattern 1 — nested object.
109            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            // Pattern 2 — qualified key.
116            if let Some(v) = map.get(&qualified)
117                && is_truthy(v)
118            {
119                return true;
120            }
121            false
122        }
123        // Pattern 3 — qualified array element.
124        Value::Array(arr) => arr
125            .iter()
126            .any(|v| matches!(v, Value::String(s) if s == &qualified)),
127        _ => false,
128    }
129}
130
131/// `true`, an object with a non-`false` `enabled` field, etc. — anything
132/// the agent would interpret as "this plugin is on".
133fn 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        // String values like "1", "true" are not standard for this field;
142        // be strict and treat them as no.
143        _ => 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    // ----------------------------------------------------------------
218    // The most important regression tests — false-positive prevention.
219    // A loose substring match would have flagged all of these as our
220    // plugin and skipped skill install.
221    // ----------------------------------------------------------------
222
223    #[test]
224    fn unrelated_plugin_with_devboy_substring_does_not_match() {
225        let home = tempdir().unwrap();
226        // A plugin called "devboy-helper" from a different marketplace.
227        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        // Claude file is missing — Claude detector says no.
324        assert!(!is_claude_plugin_enabled(home.path(), &DEVBOY_PLUGIN));
325    }
326}