//! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`. use codex_exec_server::ExecutorFileSystem; use codex_utils_absolute_path::AbsolutePathBuf; use std::path::Path; use std::path::PathBuf; const DISCOVERABLE_PLUGIN_MANIFEST_PATHS: &[&str] = &[".codex-plugin/plugin.json", ".claude-plugin/plugin.json"]; pub fn find_plugin_manifest_path(plugin_root: &Path) -> Option { DISCOVERABLE_PLUGIN_MANIFEST_PATHS .iter() .map(|relative_path| plugin_root.join(relative_path)) .find(|manifest_path| manifest_path.is_file()) } #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct RawPluginManifestName { #[serde(default)] name: String, } async fn plugin_manifest_name( fs: &dyn ExecutorFileSystem, plugin_root: &AbsolutePathBuf, ) -> Option { let mut manifest_path = None; for relative_path in DISCOVERABLE_PLUGIN_MANIFEST_PATHS { let candidate = plugin_root.join(relative_path); match fs.get_metadata(&candidate, /*sandbox*/ None).await { Ok(metadata) if metadata.is_file => { manifest_path = Some(candidate); break; } Ok(_) | Err(_) => {} } } let manifest_path = manifest_path?; let contents = fs .read_file_text(&manifest_path, /*sandbox*/ None) .await .ok()?; let RawPluginManifestName { name: raw_name } = serde_json::from_str(&contents).ok()?; Some( plugin_root .file_name() .and_then(|entry| entry.to_str()) .filter(|_| raw_name.trim().is_empty()) .unwrap_or(raw_name.as_str()) .to_string(), ) } /// Returns the plugin manifest `name` for the nearest ancestor of `path` that contains a valid /// plugin manifest (same `name` rules as full manifest loading in codex-core). pub async fn plugin_namespace_for_skill_path( fs: &dyn ExecutorFileSystem, path: &AbsolutePathBuf, ) -> Option { for ancestor in path.ancestors() { if let Some(name) = plugin_manifest_name(fs, &ancestor).await { return Some(name); } } None } #[cfg(test)] mod tests { use super::find_plugin_manifest_path; use super::plugin_namespace_for_skill_path; use codex_exec_server::LOCAL_FS; use codex_utils_absolute_path::test_support::PathBufExt; use std::fs; use tempfile::tempdir; const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json"; #[tokio::test] async fn uses_manifest_name() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("plugins/sample"); let skill_path = plugin_root.join("skills/search/SKILL.md"); fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir"); fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("mkdir manifest"); fs::write( plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample"}"#, ) .expect("write manifest"); fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill"); assert_eq!( plugin_namespace_for_skill_path(LOCAL_FS.as_ref(), &skill_path.abs()).await, Some("sample".to_string()) ); } #[tokio::test] async fn uses_name_from_alternate_discoverable_manifest_path() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("plugins/sample"); let skill_path = plugin_root.join("skills/search/SKILL.md"); let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH); fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir"); fs::create_dir_all(manifest_path.parent().expect("manifest parent")) .expect("mkdir manifest"); fs::write(&manifest_path, r#"{"name":"sample"}"#).expect("write manifest"); fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill"); assert_eq!( plugin_namespace_for_skill_path(LOCAL_FS.as_ref(), &skill_path.abs()).await, Some("sample".to_string()) ); assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path)); } }