use codex_config::HooksFile; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::find_plugin_manifest_path; use serde::Deserialize; use serde_json::Value as JsonValue; use std::fs; use std::path::Component; use std::path::Path; const MAX_DEFAULT_PROMPT_COUNT: usize = 3; const MAX_DEFAULT_PROMPT_LEN: usize = 128; #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct RawPluginManifest { #[serde(default)] name: String, #[serde(default)] version: Option, #[serde(default)] description: Option, #[serde(default)] keywords: Vec, // Keep manifest paths as raw strings so we can validate the required `./...` syntax before // resolving them under the plugin root. #[serde(default)] skills: Option, #[serde(default)] mcp_servers: Option, #[serde(default)] apps: Option, #[serde(default)] hooks: Option, #[serde(default)] interface: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginManifest { pub name: String, pub version: Option, pub description: Option, pub keywords: Vec, pub paths: PluginManifestPaths, pub interface: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginManifestPaths { pub skills: Option, pub mcp_servers: Option, pub apps: Option, pub hooks: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum PluginManifestHooks { Paths(Vec), Inline(Vec), } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PluginManifestInterface { pub display_name: Option, pub short_description: Option, pub long_description: Option, pub developer_name: Option, pub category: Option, pub capabilities: Vec, pub website_url: Option, pub privacy_policy_url: Option, pub terms_of_service_url: Option, pub default_prompt: Option>, pub brand_color: Option, pub composer_icon: Option, pub logo: Option, pub screenshots: Vec, } #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct RawPluginManifestInterface { #[serde(default)] display_name: Option, #[serde(default)] short_description: Option, #[serde(default)] long_description: Option, #[serde(default)] developer_name: Option, #[serde(default)] category: Option, #[serde(default)] capabilities: Vec, #[serde(default)] #[serde(alias = "websiteURL")] website_url: Option, #[serde(default)] #[serde(alias = "privacyPolicyURL")] privacy_policy_url: Option, #[serde(default)] #[serde(alias = "termsOfServiceURL")] terms_of_service_url: Option, #[serde(default)] default_prompt: Option, #[serde(default)] brand_color: Option, #[serde(default)] composer_icon: Option, #[serde(default)] logo: Option, #[serde(default)] screenshots: Vec, } #[derive(Debug, Deserialize)] #[serde(untagged)] enum RawPluginManifestDefaultPrompt { String(String), List(Vec), Invalid(JsonValue), } #[derive(Debug, Deserialize)] #[serde(untagged)] enum RawPluginManifestDefaultPromptEntry { String(String), Invalid(JsonValue), } #[derive(Debug, Deserialize)] #[serde(untagged)] enum RawPluginManifestHooks { Path(String), Paths(Vec), Inline(HooksFile), InlineList(Vec), Invalid(JsonValue), } pub fn load_plugin_manifest(plugin_root: &Path) -> Option { let manifest_path = find_plugin_manifest_path(plugin_root)?; let contents = fs::read_to_string(&manifest_path).ok()?; match serde_json::from_str::(&contents) { Ok(manifest) => { let RawPluginManifest { name: raw_name, version, description, keywords, skills, mcp_servers, apps, hooks, interface, } = manifest; let name = plugin_root .file_name() .and_then(|entry| entry.to_str()) .filter(|_| raw_name.trim().is_empty()) .unwrap_or(&raw_name) .to_string(); let version = version.and_then(|version| { let version = version.trim(); (!version.is_empty()).then(|| version.to_string()) }); let interface = interface.and_then(|interface| { let RawPluginManifestInterface { display_name, short_description, long_description, developer_name, category, capabilities, website_url, privacy_policy_url, terms_of_service_url, default_prompt, brand_color, composer_icon, logo, screenshots, } = interface; let interface = PluginManifestInterface { display_name, short_description, long_description, developer_name, category, capabilities, website_url, privacy_policy_url, terms_of_service_url, default_prompt: resolve_default_prompts(plugin_root, default_prompt.as_ref()), brand_color, composer_icon: resolve_interface_asset_path( plugin_root, "interface.composerIcon", composer_icon.as_deref(), ), logo: resolve_interface_asset_path( plugin_root, "interface.logo", logo.as_deref(), ), screenshots: screenshots .iter() .filter_map(|screenshot| { resolve_interface_asset_path( plugin_root, "interface.screenshots", Some(screenshot), ) }) .collect(), }; let has_fields = interface.display_name.is_some() || interface.short_description.is_some() || interface.long_description.is_some() || interface.developer_name.is_some() || interface.category.is_some() || !interface.capabilities.is_empty() || interface.website_url.is_some() || interface.privacy_policy_url.is_some() || interface.terms_of_service_url.is_some() || interface.default_prompt.is_some() || interface.brand_color.is_some() || interface.composer_icon.is_some() || interface.logo.is_some() || !interface.screenshots.is_empty(); has_fields.then_some(interface) }); Some(PluginManifest { name, version, description, keywords, paths: PluginManifestPaths { skills: resolve_manifest_path(plugin_root, "skills", skills.as_deref()), mcp_servers: resolve_manifest_path( plugin_root, "mcpServers", mcp_servers.as_deref(), ), apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()), hooks: resolve_manifest_hooks(plugin_root, hooks), }, interface, }) } Err(err) => { tracing::warn!( path = %manifest_path.display(), "failed to parse plugin manifest: {err}" ); None } } } fn resolve_manifest_hooks( plugin_root: &Path, hooks: Option, ) -> Option { match hooks? { RawPluginManifestHooks::Path(path) => { resolve_manifest_path(plugin_root, "hooks", Some(&path)) .map(|path| PluginManifestHooks::Paths(vec![path])) } RawPluginManifestHooks::Paths(paths) => { let hooks = paths .iter() .filter_map(|path| resolve_manifest_path(plugin_root, "hooks", Some(path))) .collect::>(); (!hooks.is_empty()).then_some(PluginManifestHooks::Paths(hooks)) } RawPluginManifestHooks::Inline(hooks) => Some(PluginManifestHooks::Inline(vec![hooks])), RawPluginManifestHooks::InlineList(hooks) => { (!hooks.is_empty()).then_some(PluginManifestHooks::Inline(hooks)) } RawPluginManifestHooks::Invalid(value) => { tracing::warn!( "ignoring hooks: expected a string, string array, object, or object array; found {}", json_value_type(&value) ); None } } } fn resolve_interface_asset_path( plugin_root: &Path, field: &'static str, path: Option<&str>, ) -> Option { resolve_manifest_path(plugin_root, field, path) } fn resolve_default_prompts( plugin_root: &Path, value: Option<&RawPluginManifestDefaultPrompt>, ) -> Option> { match value? { RawPluginManifestDefaultPrompt::String(prompt) => { resolve_default_prompt_str(plugin_root, "interface.defaultPrompt", prompt) .map(|prompt| vec![prompt]) } RawPluginManifestDefaultPrompt::List(values) => { let mut prompts = Vec::new(); for (index, item) in values.iter().enumerate() { if prompts.len() >= MAX_DEFAULT_PROMPT_COUNT { warn_invalid_default_prompt( plugin_root, "interface.defaultPrompt", &format!("maximum of {MAX_DEFAULT_PROMPT_COUNT} prompts is supported"), ); break; } match item { RawPluginManifestDefaultPromptEntry::String(prompt) => { let field = format!("interface.defaultPrompt[{index}]"); if let Some(prompt) = resolve_default_prompt_str(plugin_root, &field, prompt) { prompts.push(prompt); } } RawPluginManifestDefaultPromptEntry::Invalid(value) => { let field = format!("interface.defaultPrompt[{index}]"); warn_invalid_default_prompt( plugin_root, &field, &format!("expected a string, found {}", json_value_type(value)), ); } } } (!prompts.is_empty()).then_some(prompts) } RawPluginManifestDefaultPrompt::Invalid(value) => { warn_invalid_default_prompt( plugin_root, "interface.defaultPrompt", &format!( "expected a string or array of strings, found {}", json_value_type(value) ), ); None } } } fn resolve_default_prompt_str(plugin_root: &Path, field: &str, prompt: &str) -> Option { let prompt = prompt.split_whitespace().collect::>().join(" "); if prompt.is_empty() { warn_invalid_default_prompt(plugin_root, field, "prompt must not be empty"); return None; } if prompt.chars().count() > MAX_DEFAULT_PROMPT_LEN { warn_invalid_default_prompt( plugin_root, field, &format!("prompt must be at most {MAX_DEFAULT_PROMPT_LEN} characters"), ); return None; } Some(prompt) } fn warn_invalid_default_prompt(plugin_root: &Path, field: &str, message: &str) { if let Some(manifest_path) = find_plugin_manifest_path(plugin_root) { tracing::warn!( path = %manifest_path.display(), "ignoring {field}: {message}" ); } else { tracing::warn!("ignoring {field}: {message}"); } } fn json_value_type(value: &JsonValue) -> &'static str { match value { JsonValue::Null => "null", JsonValue::Bool(_) => "boolean", JsonValue::Number(_) => "number", JsonValue::String(_) => "string", JsonValue::Array(_) => "array", JsonValue::Object(_) => "object", } } fn resolve_manifest_path( plugin_root: &Path, field: &'static str, path: Option<&str>, ) -> Option { // `plugin.json` paths are required to be relative to the plugin root and we return the // normalized absolute path to the rest of the system. let path = path?; if path.is_empty() { return None; } let Some(relative_path) = path.strip_prefix("./") else { tracing::warn!("ignoring {field}: path must start with `./` relative to plugin root"); return None; }; if relative_path.is_empty() { tracing::warn!("ignoring {field}: path must not be `./`"); return None; } let mut normalized = std::path::PathBuf::new(); for component in Path::new(relative_path).components() { match component { Component::Normal(component) => normalized.push(component), Component::ParentDir => { tracing::warn!("ignoring {field}: path must not contain '..'"); return None; } _ => { tracing::warn!("ignoring {field}: path must stay within the plugin root"); return None; } } } AbsolutePathBuf::try_from(plugin_root.join(normalized)) .map_err(|err| { tracing::warn!("ignoring {field}: path must resolve to an absolute path: {err}"); err }) .ok() } #[cfg(test)] mod tests { use super::MAX_DEFAULT_PROMPT_LEN; use super::PluginManifest; use super::load_plugin_manifest; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; use tempfile::tempdir; const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json"; fn write_manifest(plugin_root: &Path, version: Option<&str>, interface: &str) { fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); let version = version .map(|version| format!(" \"version\": \"{version}\",\n")) .unwrap_or_default(); fs::write( plugin_root.join(".codex-plugin/plugin.json"), format!( r#"{{ "name": "demo-plugin", {version} "interface": {interface} }}"# ), ) .expect("write manifest"); } fn write_alternate_plugin_manifest(plugin_root: &Path, contents: &str) { let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH); fs::create_dir_all(manifest_path.parent().expect("manifest parent")) .expect("create manifest dir"); fs::write(manifest_path, contents).expect("write manifest"); } fn load_manifest(plugin_root: &Path) -> PluginManifest { load_plugin_manifest(plugin_root).expect("load plugin manifest") } #[test] fn plugin_interface_accepts_legacy_default_prompt_string() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("demo-plugin"); write_manifest( &plugin_root, /*version*/ None, r#"{ "displayName": "Demo Plugin", "defaultPrompt": " Summarize my inbox " }"#, ); let manifest = load_manifest(&plugin_root); let interface = manifest.interface.expect("plugin interface"); assert_eq!( interface.default_prompt, Some(vec!["Summarize my inbox".to_string()]) ); } #[test] fn plugin_interface_normalizes_default_prompt_array() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("demo-plugin"); let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1); write_manifest( &plugin_root, /*version*/ None, &format!( r#"{{ "displayName": "Demo Plugin", "defaultPrompt": [ " Summarize my inbox ", 123, "{too_long}", " ", "Draft the reply ", "Find my next action", "Archive old mail" ] }}"# ), ); let manifest = load_manifest(&plugin_root); let interface = manifest.interface.expect("plugin interface"); assert_eq!( interface.default_prompt, Some(vec![ "Summarize my inbox".to_string(), "Draft the reply".to_string(), "Find my next action".to_string(), ]) ); } #[test] fn plugin_interface_ignores_invalid_default_prompt_shape() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("demo-plugin"); write_manifest( &plugin_root, /*version*/ None, r#"{ "displayName": "Demo Plugin", "defaultPrompt": { "text": "Summarize my inbox" } }"#, ); let manifest = load_manifest(&plugin_root); let interface = manifest.interface.expect("plugin interface"); assert_eq!(interface.default_prompt, None); } #[test] fn plugin_manifest_reads_trimmed_version() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("demo-plugin"); write_manifest( &plugin_root, Some(" 1.2.3-beta+7 "), r#"{ "displayName": "Demo Plugin" }"#, ); let manifest = load_manifest(&plugin_root); assert_eq!(manifest.version, Some("1.2.3-beta+7".to_string())); } #[test] fn plugin_manifest_reads_keywords() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("demo-plugin"); fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); fs::write( plugin_root.join(".codex-plugin/plugin.json"), r#"{ "name": "demo-plugin", "keywords": ["api-key", "developer tools"] }"#, ) .expect("write manifest"); let manifest = load_manifest(&plugin_root); assert_eq!( manifest.keywords, vec!["api-key".to_string(), "developer tools".to_string()] ); } #[test] fn plugin_manifest_uses_alternate_discoverable_path() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("demo-plugin"); write_alternate_plugin_manifest( &plugin_root, r#"{ "name": "demo-plugin", "version": " 2.0.0 ", "interface": { "displayName": "Fallback Plugin" } }"#, ); let manifest = load_manifest(&plugin_root); assert_eq!(manifest.version, Some("2.0.0".to_string())); assert_eq!( manifest .interface .as_ref() .and_then(|interface| interface.display_name.as_deref()), Some("Fallback Plugin") ); } }