//! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`. use codex_exec_server::ExecutorFileSystem; use codex_exec_server_protocol::DISCOVERABLE_PLUGIN_MANIFEST_PATHS; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use std::path::Path; use std::path::PathBuf; pub const AGENT_PLUGIN_MANIFEST_RELATIVE_PATH: &str = "plugin.json"; /// Published Agent Plugins v1 manifest schema: /// https://github.com/agentplugins/agent-plugins-spec/blob/main/schemas/1.0.0/plugin.schema.json pub const AGENT_PLUGIN_SCHEMA_URI: &str = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; pub const SUPPORTED_AGENT_PLUGIN_SCHEMA_URIS: &[&str] = &[AGENT_PLUGIN_SCHEMA_URI]; pub const AGENT_PLUGIN_SCHEMA_PREFIX: &str = "https://agent-plugins.org/schemas/"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AgentPluginSchemaStatus { Supported, Unsupported, Unrelated, } pub fn agent_plugin_schema_status(contents: &str) -> AgentPluginSchemaStatus { let Ok(value) = serde_json::from_str::(contents) else { return AgentPluginSchemaStatus::Unrelated; }; let Some(schema) = value.get("$schema").and_then(serde_json::Value::as_str) else { return AgentPluginSchemaStatus::Unrelated; }; if SUPPORTED_AGENT_PLUGIN_SCHEMA_URIS.contains(&schema) { AgentPluginSchemaStatus::Supported } else if schema.starts_with(AGENT_PLUGIN_SCHEMA_PREFIX) { AgentPluginSchemaStatus::Unsupported } else { AgentPluginSchemaStatus::Unrelated } } pub fn find_plugin_manifest_path(plugin_root: &Path) -> Option { let agent_manifest_path = plugin_root.join(AGENT_PLUGIN_MANIFEST_RELATIVE_PATH); match std::fs::symlink_metadata(&agent_manifest_path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.file_type().is_file() => { return None; } Ok(_) => { if std::fs::read_to_string(&agent_manifest_path) .ok() .is_some_and(|contents| { agent_plugin_schema_status(&contents) != AgentPluginSchemaStatus::Unrelated }) { return Some(agent_manifest_path); } } Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} Err(_) => return None, } 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, } /// Returns the plugin manifest `name` defined directly below `plugin_root`. pub async fn plugin_namespace_for_root_uri( fs: &dyn ExecutorFileSystem, plugin_root: &PathUri, ) -> Option { let mut manifest_path = None; for relative_path in DISCOVERABLE_PLUGIN_MANIFEST_PATHS { let candidate = plugin_root.join(relative_path).ok()?; match fs.get_metadata(&candidate, /*sandbox*/ None).await { Ok(metadata) if metadata.is_file => { manifest_path = Some(candidate); break; } Ok(_) | Err(_) => {} } } 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 .basename() .filter(|_| raw_name.trim().is_empty()) .unwrap_or(raw_name), ) } /// 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 { plugin_namespace_for_skill_uri(fs, &PathUri::from_abs_path(path)).await } /// Returns the plugin manifest `name` for the nearest URI ancestor of `path`. pub async fn plugin_namespace_for_skill_uri( fs: &dyn ExecutorFileSystem, path: &PathUri, ) -> Option { let mut ancestor = Some(path.clone()); while let Some(path) = ancestor { if let Some(name) = plugin_namespace_for_root_uri(fs, &path).await { return Some(name); } ancestor = path.parent(); } None } #[cfg(test)] mod tests { use super::AGENT_PLUGIN_MANIFEST_RELATIVE_PATH; use super::AGENT_PLUGIN_SCHEMA_URI; 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_CLA_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json"; const ALTERNATE_PLUGIN_CUR_MANIFEST_RELATIVE_PATH: &str = ".cursor-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_CLA_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)); } #[tokio::test] async fn uses_name_from_cur_plugin_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_CUR_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)); } #[test] fn recognizes_schema_declared_root_plugin_manifest() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("plugins/portable"); let skill_path = plugin_root.join("skills/search/SKILL.md"); fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir"); let manifest_path = plugin_root.join(AGENT_PLUGIN_MANIFEST_RELATIVE_PATH); fs::write( &manifest_path, format!(r#"{{"$schema":"{AGENT_PLUGIN_SCHEMA_URI}","name":"portable"}}"#), ) .expect("write manifest"); assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path)); } #[test] fn ignores_unrelated_root_plugin_manifest_before_legacy_fallback() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("plugins/sample"); let legacy_path = plugin_root.join(".codex-plugin/plugin.json"); fs::create_dir_all(legacy_path.parent().expect("parent")).expect("mkdir"); fs::write(plugin_root.join("plugin.json"), r#"{"name":"npm-package"}"#) .expect("write unrelated root"); fs::write(&legacy_path, r#"{"name":"sample"}"#).expect("write legacy"); assert_eq!(find_plugin_manifest_path(&plugin_root), Some(legacy_path)); } #[test] fn rejects_nonregular_root_plugin_manifest() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("plugins/sample"); let legacy_path = plugin_root.join(".codex-plugin/plugin.json"); fs::create_dir_all(plugin_root.join("plugin.json")).expect("root manifest directory"); fs::create_dir_all(legacy_path.parent().expect("parent")).expect("legacy parent"); fs::write(&legacy_path, r#"{"name":"sample"}"#).expect("legacy manifest"); assert_eq!(find_plugin_manifest_path(&plugin_root), None); } #[cfg(unix)] #[test] fn rejects_symlinked_root_plugin_manifest() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("plugins/sample"); let manifest_target = tmp.path().join("manifest.json"); let legacy_path = plugin_root.join(".codex-plugin/plugin.json"); fs::create_dir_all(&plugin_root).expect("plugin root"); fs::write( &manifest_target, format!(r#"{{"$schema":"{AGENT_PLUGIN_SCHEMA_URI}","name":"sample"}}"#), ) .expect("manifest target"); std::os::unix::fs::symlink(&manifest_target, plugin_root.join("plugin.json")) .expect("root manifest symlink"); fs::create_dir_all(legacy_path.parent().expect("parent")).expect("legacy parent"); fs::write(&legacy_path, r#"{"name":"sample"}"#).expect("legacy manifest"); assert_eq!(find_plugin_manifest_path(&plugin_root), None); } #[test] fn preserves_codex_claude_cursor_legacy_precedence() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("plugins/sample"); let codex_path = plugin_root.join(".codex-plugin/plugin.json"); let claude_path = plugin_root.join(".claude-plugin/plugin.json"); let cursor_path = plugin_root.join(".cursor-plugin/plugin.json"); for path in [&codex_path, &claude_path, &cursor_path] { fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); fs::write(path, r#"{"name":"sample"}"#).expect("write manifest"); } assert_eq!( find_plugin_manifest_path(&plugin_root), Some(codex_path.clone()) ); fs::remove_file(codex_path).expect("remove Codex manifest"); assert_eq!(find_plugin_manifest_path(&plugin_root), Some(claude_path)); } }