Skip to main content

codex_utils_plugins/
plugin_namespace.rs

1//! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`.
2
3use codex_exec_server::ExecutorFileSystem;
4use codex_exec_server_protocol::DISCOVERABLE_PLUGIN_MANIFEST_PATHS;
5use codex_utils_absolute_path::AbsolutePathBuf;
6use codex_utils_path_uri::PathUri;
7use std::path::Path;
8use std::path::PathBuf;
9
10pub fn find_plugin_manifest_path(plugin_root: &Path) -> Option<PathBuf> {
11    DISCOVERABLE_PLUGIN_MANIFEST_PATHS
12        .iter()
13        .map(|relative_path| plugin_root.join(relative_path))
14        .find(|manifest_path| manifest_path.is_file())
15}
16
17#[derive(serde::Deserialize)]
18#[serde(rename_all = "camelCase")]
19struct RawPluginManifestName {
20    #[serde(default)]
21    name: String,
22}
23
24/// Returns the plugin manifest `name` defined directly below `plugin_root`.
25pub async fn plugin_namespace_for_root_uri(
26    fs: &dyn ExecutorFileSystem,
27    plugin_root: &PathUri,
28) -> Option<String> {
29    let mut manifest_path = None;
30    for relative_path in DISCOVERABLE_PLUGIN_MANIFEST_PATHS {
31        let candidate = plugin_root.join(relative_path).ok()?;
32        match fs.get_metadata(&candidate, /*sandbox*/ None).await {
33            Ok(metadata) if metadata.is_file => {
34                manifest_path = Some(candidate);
35                break;
36            }
37            Ok(_) | Err(_) => {}
38        }
39    }
40    let contents = fs
41        .read_file_text(&manifest_path?, /*sandbox*/ None)
42        .await
43        .ok()?;
44    let RawPluginManifestName { name: raw_name } = serde_json::from_str(&contents).ok()?;
45    Some(
46        plugin_root
47            .basename()
48            .filter(|_| raw_name.trim().is_empty())
49            .unwrap_or(raw_name),
50    )
51}
52
53/// Returns the plugin manifest `name` for the nearest ancestor of `path` that contains a valid
54/// plugin manifest (same `name` rules as full manifest loading in codex-core).
55pub async fn plugin_namespace_for_skill_path(
56    fs: &dyn ExecutorFileSystem,
57    path: &AbsolutePathBuf,
58) -> Option<String> {
59    plugin_namespace_for_skill_uri(fs, &PathUri::from_abs_path(path)).await
60}
61
62/// Returns the plugin manifest `name` for the nearest URI ancestor of `path`.
63pub async fn plugin_namespace_for_skill_uri(
64    fs: &dyn ExecutorFileSystem,
65    path: &PathUri,
66) -> Option<String> {
67    let mut ancestor = Some(path.clone());
68    while let Some(path) = ancestor {
69        if let Some(name) = plugin_namespace_for_root_uri(fs, &path).await {
70            return Some(name);
71        }
72        ancestor = path.parent();
73    }
74    None
75}
76
77#[cfg(test)]
78mod tests {
79    use super::find_plugin_manifest_path;
80    use super::plugin_namespace_for_skill_path;
81    use codex_exec_server::LOCAL_FS;
82    use codex_utils_absolute_path::test_support::PathBufExt;
83    use std::fs;
84    use tempfile::tempdir;
85
86    const ALTERNATE_PLUGIN_CLA_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
87    const ALTERNATE_PLUGIN_CUR_MANIFEST_RELATIVE_PATH: &str = ".cursor-plugin/plugin.json";
88
89    #[tokio::test]
90    async fn uses_manifest_name() {
91        let tmp = tempdir().expect("tempdir");
92        let plugin_root = tmp.path().join("plugins/sample");
93        let skill_path = plugin_root.join("skills/search/SKILL.md");
94
95        fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
96        fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("mkdir manifest");
97        fs::write(
98            plugin_root.join(".codex-plugin/plugin.json"),
99            r#"{"name":"sample"}"#,
100        )
101        .expect("write manifest");
102        fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill");
103
104        assert_eq!(
105            plugin_namespace_for_skill_path(LOCAL_FS.as_ref(), &skill_path.abs()).await,
106            Some("sample".to_string())
107        );
108    }
109
110    #[tokio::test]
111    async fn uses_name_from_alternate_discoverable_manifest_path() {
112        let tmp = tempdir().expect("tempdir");
113        let plugin_root = tmp.path().join("plugins/sample");
114        let skill_path = plugin_root.join("skills/search/SKILL.md");
115        let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_CLA_MANIFEST_RELATIVE_PATH);
116
117        fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
118        fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
119            .expect("mkdir manifest");
120        fs::write(&manifest_path, r#"{"name":"sample"}"#).expect("write manifest");
121        fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill");
122
123        assert_eq!(
124            plugin_namespace_for_skill_path(LOCAL_FS.as_ref(), &skill_path.abs()).await,
125            Some("sample".to_string())
126        );
127        assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path));
128    }
129
130    #[tokio::test]
131    async fn uses_name_from_cur_plugin_manifest_path() {
132        let tmp = tempdir().expect("tempdir");
133        let plugin_root = tmp.path().join("plugins/sample");
134        let skill_path = plugin_root.join("skills/search/SKILL.md");
135        let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_CUR_MANIFEST_RELATIVE_PATH);
136
137        fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
138        fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
139            .expect("mkdir manifest");
140        fs::write(&manifest_path, r#"{"name":"sample"}"#).expect("write manifest");
141        fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill");
142
143        assert_eq!(
144            plugin_namespace_for_skill_path(LOCAL_FS.as_ref(), &skill_path.abs()).await,
145            Some("sample".to_string())
146        );
147        assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path));
148    }
149}