codex_utils_plugins/
plugin_namespace.rs1use 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
24pub 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, 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?, 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
53pub 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
62pub 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}