use crate::manifest::PluginManifestInterface; use crate::manifest::load_plugin_manifest; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_git_utils::get_git_repo_root; use codex_plugin::PluginId; use codex_plugin::PluginIdError; use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde_json::Map as JsonMap; use serde_json::Value as JsonValue; use std::fs; use std::io; use std::path::Component; use std::path::Path; use std::path::PathBuf; use tracing::warn; const MARKETPLACE_MANIFEST_RELATIVE_PATHS: &[&str] = &[ ".agents/plugins/marketplace.json", ".agents/plugins/api_marketplace.json", ".claude-plugin/marketplace.json", ".cursor-plugin/marketplace.json", ]; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolvedMarketplacePlugin { pub plugin_id: PluginId, pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, pub manifest: Option, pub manifest_fallback: MarketplacePluginManifestFallback, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Marketplace { pub name: String, pub path: AbsolutePathBuf, pub interface: Option, pub plugins: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarketplaceListError { pub path: AbsolutePathBuf, pub message: String, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct MarketplaceListOutcome { pub marketplaces: Vec, pub errors: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarketplaceInterface { pub display_name: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarketplacePluginManifestFallback { contents: String, has_metadata: bool, } impl MarketplacePluginManifestFallback { pub fn contents(&self) -> &str { &self.contents } pub(crate) fn contents_if_has_metadata(&self) -> Option<&str> { self.has_metadata.then_some(self.contents()) } pub(crate) fn parse_for_plugin_root( &self, plugin_root: &Path, ) -> Option { crate::manifest::parse_plugin_manifest( plugin_root, &fallback_plugin_manifest_path(plugin_root), &self.contents, ) .ok() } pub(crate) fn parse_for_listing(&self) -> Option { // Materialized sources have no plugin root before install. Parse against a host-native // synthetic absolute root, then discard path-bearing fields so listings expose metadata only. let plugin_root = Path::new(if cfg!(windows) { r"C:\" } else { "/" }); let mut manifest = crate::manifest::parse_plugin_manifest( plugin_root, &fallback_plugin_manifest_path(plugin_root), &self.contents, ) .ok()?; manifest.paths = crate::manifest::PluginManifestPaths { skills: Vec::new(), mcp_servers: None, apps: None, hooks: None, }; if let Some(interface) = manifest.interface.as_mut() { interface.composer_icon = None; interface.logo = None; interface.logo_dark = None; interface.screenshots.clear(); } Some(manifest) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarketplacePlugin { pub name: String, pub local_version: Option, pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, pub keywords: Vec, pub manifest_fallback: Option, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum MarketplacePluginSource { Local { path: AbsolutePathBuf, }, Git { url: String, path: Option, ref_name: Option, sha: Option, }, Npm { package: String, version: Option, registry: Option, }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum NpmPackageScope { Scoped, Unscoped, } impl MarketplacePluginSource { pub(crate) fn is_install_materialized(&self) -> bool { matches!(self, Self::Git { .. } | Self::Npm { .. }) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarketplacePluginPolicy { pub installation: MarketplacePluginInstallPolicy, pub authentication: MarketplacePluginAuthPolicy, // TODO: Surface or enforce product gating at the Codex/plugin consumer boundary instead of // only carrying it through core marketplace metadata. pub products: Option>, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] pub enum MarketplacePluginInstallPolicy { #[serde(rename = "NOT_AVAILABLE")] NotAvailable, #[default] #[serde(rename = "AVAILABLE")] Available, #[serde(rename = "INSTALLED_BY_DEFAULT")] InstalledByDefault, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] pub enum MarketplacePluginAuthPolicy { #[default] #[serde(rename = "ON_INSTALL")] OnInstall, #[serde(rename = "ON_USE")] OnUse, } impl From for PluginInstallPolicy { fn from(value: MarketplacePluginInstallPolicy) -> Self { match value { MarketplacePluginInstallPolicy::NotAvailable => Self::NotAvailable, MarketplacePluginInstallPolicy::Available => Self::Available, MarketplacePluginInstallPolicy::InstalledByDefault => Self::InstalledByDefault, } } } impl From for PluginAuthPolicy { fn from(value: MarketplacePluginAuthPolicy) -> Self { match value { MarketplacePluginAuthPolicy::OnInstall => Self::OnInstall, MarketplacePluginAuthPolicy::OnUse => Self::OnUse, } } } #[derive(Debug, thiserror::Error)] pub enum MarketplaceError { #[error("{context}: {source}")] Io { context: &'static str, #[source] source: io::Error, }, #[error("marketplace file `{path}` does not exist")] MarketplaceNotFound { path: PathBuf }, #[error("invalid marketplace file `{path}`: {message}")] InvalidMarketplaceFile { path: PathBuf, message: String }, #[error("plugin `{plugin_name}` was not found in marketplace `{marketplace_name}`")] PluginNotFound { plugin_name: String, marketplace_name: String, }, #[error( "plugin `{plugin_name}` is not available for install in marketplace `{marketplace_name}`" )] PluginNotAvailable { plugin_name: String, marketplace_name: String, }, #[error("plugins feature is disabled")] PluginsDisabled, #[error("{0}")] InvalidPlugin(String), } impl MarketplaceError { fn io(context: &'static str, source: io::Error) -> Self { Self::Io { context, source } } } pub fn find_marketplace_plugin( marketplace_path: &AbsolutePathBuf, plugin_name: &str, ) -> Result { let marketplace = load_raw_marketplace_manifest(marketplace_path)?; let marketplace_name = marketplace.name; let marketplace_name_for_not_found = marketplace_name.clone(); for plugin in marketplace.plugins { if plugin.name != plugin_name { continue; } if let Some(plugin) = resolve_marketplace_plugin_entry(marketplace_path, &marketplace_name, plugin)? { return Ok(plugin); } } Err(MarketplaceError::PluginNotFound { plugin_name: plugin_name.to_string(), marketplace_name: marketplace_name_for_not_found, }) } pub fn find_installable_marketplace_plugin( marketplace_path: &AbsolutePathBuf, plugin_name: &str, restriction_product: Option, ) -> Result { let resolved = find_marketplace_plugin(marketplace_path, plugin_name)?; let product_allowed = match resolved.policy.products.as_deref() { None => true, Some([]) => false, Some(products) => { restriction_product.is_some_and(|product| product.matches_product_restriction(products)) } }; if resolved.policy.installation == MarketplacePluginInstallPolicy::NotAvailable || !product_allowed { return Err(MarketplaceError::PluginNotAvailable { plugin_name: resolved.plugin_id.plugin_name, marketplace_name: resolved.plugin_id.marketplace_name, }); } Ok(resolved) } pub fn list_marketplaces( additional_roots: &[AbsolutePathBuf], ) -> Result { list_marketplaces_with_home(additional_roots, home_dir().as_deref()) } pub(crate) fn home_dir() -> Option { ["HOME", "USERPROFILE"] .into_iter() .filter_map(std::env::var_os) .filter(|value| !value.is_empty()) .map(PathBuf::from) .find(|path| path.is_absolute()) .or_else(dirs::home_dir) } pub fn validate_marketplace_root(root: &Path) -> Result { let Some(path) = find_marketplace_manifest_path(root) else { return Err(MarketplaceError::InvalidMarketplaceFile { path: root.to_path_buf(), message: "marketplace root does not contain a supported manifest".to_string(), }); }; let marketplace = load_marketplace(&path)?; Ok(marketplace.name) } pub fn find_marketplace_manifest_path(root: &Path) -> Option { MARKETPLACE_MANIFEST_RELATIVE_PATHS .iter() .find_map(|relative_path| { let path = root.join(relative_path); if !path.is_file() { return None; } AbsolutePathBuf::try_from(path).ok() }) } fn supported_marketplace_manifest_path(path: &Path) -> Option { if !path.is_file() { return None; } if !MARKETPLACE_MANIFEST_RELATIVE_PATHS .iter() .any(|relative_path| marketplace_root_from_layout(path, relative_path).is_some()) { return None; } AbsolutePathBuf::try_from(path.to_path_buf()).ok() } fn invalid_marketplace_layout_error(path: &AbsolutePathBuf) -> MarketplaceError { MarketplaceError::InvalidMarketplaceFile { path: path.to_path_buf(), message: "marketplace file is not in a supported location".to_string(), } } fn marketplace_root_from_layout(marketplace_path: &Path, relative_path: &str) -> Option { let mut current = marketplace_path; for component in Path::new(relative_path).components().rev() { let expected = match component { Component::Normal(expected) => expected, _ => return None, }; if current.file_name() != Some(expected) { return None; } current = current.parent()?; } Some(current.to_path_buf()) } pub fn load_marketplace(path: &AbsolutePathBuf) -> Result { let marketplace = load_raw_marketplace_manifest(path)?; let mut plugins = Vec::new(); for plugin in marketplace.plugins { let plugin = match resolve_marketplace_plugin_entry(path, &marketplace.name, plugin) { Ok(Some(plugin)) => plugin, Ok(None) => continue, Err(MarketplaceError::InvalidPlugin(message)) => { warn!( path = %path.display(), marketplace = %marketplace.name, error = %message, "skipping invalid marketplace plugin" ); continue; } Err(err) => return Err(err), }; let manifest_fallback = plugin .manifest_fallback .contents_if_has_metadata() .map(|_| plugin.manifest_fallback.clone()); let local_version = plugin .manifest .as_ref() .and_then(|manifest| manifest.version.clone()); let keywords = plugin .manifest .map(|manifest| manifest.keywords) .unwrap_or_default(); plugins.push(MarketplacePlugin { name: plugin.plugin_id.plugin_name, local_version, source: plugin.source, policy: plugin.policy, interface: plugin.interface, keywords, manifest_fallback, }); } Ok(Marketplace { name: marketplace.name, path: path.clone(), interface: resolve_marketplace_interface(marketplace.interface), plugins, }) } #[doc(hidden)] pub fn list_marketplaces_with_home( additional_roots: &[AbsolutePathBuf], home_dir: Option<&Path>, ) -> Result { let mut outcome = MarketplaceListOutcome::default(); for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) { match load_marketplace(&marketplace_path) { Ok(marketplace) => outcome.marketplaces.push(marketplace), Err(err) => { warn!( path = %marketplace_path.display(), error = %err, "skipping marketplace that failed to load" ); outcome.errors.push(MarketplaceListError { path: marketplace_path, message: err.to_string(), }); } } } Ok(outcome) } fn discover_marketplace_paths_from_roots( additional_roots: &[AbsolutePathBuf], home_dir: Option<&Path>, ) -> Vec { let mut paths = Vec::new(); if let Some(home) = home_dir && let Some(path) = find_marketplace_manifest_path(home) { paths.push(path); } for root in additional_roots { if let Some(path) = supported_marketplace_manifest_path(root.as_path()) && !paths.contains(&path) { paths.push(path); continue; } // Curated marketplaces can now come from an HTTP-downloaded directory that is not a git // checkout, so check the root directly before falling back to repo-root discovery. if let Some(path) = find_marketplace_manifest_path(root.as_path()) && !paths.contains(&path) { paths.push(path); continue; } if let Some(repo_root) = get_git_repo_root(root.as_path()) && let Ok(repo_root) = AbsolutePathBuf::try_from(repo_root) && let Some(path) = find_marketplace_manifest_path(repo_root.as_path()) && !paths.contains(&path) { paths.push(path); } } paths } fn load_raw_marketplace_manifest( path: &AbsolutePathBuf, ) -> Result { let contents = fs::read_to_string(path.as_path()).map_err(|err| { if err.kind() == io::ErrorKind::NotFound { MarketplaceError::MarketplaceNotFound { path: path.to_path_buf(), } } else { MarketplaceError::io("failed to read marketplace file", err) } })?; serde_json::from_str(&contents).map_err(|err| MarketplaceError::InvalidMarketplaceFile { path: path.to_path_buf(), message: err.to_string(), }) } fn resolve_marketplace_plugin_entry( marketplace_path: &AbsolutePathBuf, marketplace_name: &str, plugin: RawMarketplaceManifestPlugin, ) -> Result, MarketplaceError> { let RawMarketplaceManifestPlugin { name, source, policy, category, manifest_fields, } = plugin; let Some(source) = resolve_supported_plugin_source(marketplace_path, &name, source) else { return Ok(None); }; let manifest_fallback = marketplace_plugin_manifest_fallback(&name, category.as_deref(), &manifest_fields); let manifest = match &source { MarketplacePluginSource::Local { path } => { if codex_utils_plugins::find_plugin_manifest_path(path.as_path()).is_some() { load_plugin_manifest(path.as_path()) } else if manifest_fallback.has_metadata { manifest_fallback.parse_for_plugin_root(path.as_path()) } else { None } } MarketplacePluginSource::Git { .. } | MarketplacePluginSource::Npm { .. } if manifest_fallback.has_metadata => { manifest_fallback.parse_for_listing() } MarketplacePluginSource::Git { .. } | MarketplacePluginSource::Npm { .. } => None, }; let interface = plugin_interface_with_marketplace_category( manifest .as_ref() .and_then(|manifest| manifest.interface.clone()), category, ); Ok(Some(ResolvedMarketplacePlugin { plugin_id: PluginId::new(name, marketplace_name.to_string()).map_err(|err| match err { PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message), })?, source, policy: MarketplacePluginPolicy { installation: policy.installation, authentication: policy.authentication, products: policy.products, }, interface, manifest, manifest_fallback, })) } fn resolve_supported_plugin_source( marketplace_path: &AbsolutePathBuf, plugin_name: &str, source: RawMarketplaceManifestPluginSource, ) -> Option { match source { RawMarketplaceManifestPluginSource::Unsupported(_) => { warn!( path = %marketplace_path.display(), plugin = plugin_name, "skipping marketplace plugin with unsupported source" ); None } source => match resolve_plugin_source(marketplace_path, source) { Ok(source) => Some(source), Err(err) => { warn!( path = %marketplace_path.display(), plugin = plugin_name, error = %err, "skipping marketplace plugin that failed to resolve" ); None } }, } } fn resolve_plugin_source( marketplace_path: &AbsolutePathBuf, source: RawMarketplaceManifestPluginSource, ) -> Result { match source { RawMarketplaceManifestPluginSource::Path(path) | RawMarketplaceManifestPluginSource::Object( RawMarketplaceManifestPluginSourceObject::Local { path }, ) => Ok(MarketplacePluginSource::Local { path: resolve_local_plugin_source_path(marketplace_path, &path)?, }), RawMarketplaceManifestPluginSource::Object( RawMarketplaceManifestPluginSourceObject::Url { url, path, ref_name, sha, }, ) => Ok(MarketplacePluginSource::Git { url: normalize_git_plugin_source_url(marketplace_path, &url)?, path: path .as_deref() .map(|path| normalize_remote_plugin_subdir(marketplace_path, path)) .transpose()?, ref_name: normalize_optional_git_selector(&ref_name), sha: normalize_optional_git_selector(&sha), }), RawMarketplaceManifestPluginSource::Object( RawMarketplaceManifestPluginSourceObject::GitSubdir { url, path, ref_name, sha, }, ) => Ok(MarketplacePluginSource::Git { url: normalize_git_plugin_source_url(marketplace_path, &url)?, path: Some(normalize_remote_plugin_subdir(marketplace_path, &path)?), ref_name: normalize_optional_git_selector(&ref_name), sha: normalize_optional_git_selector(&sha), }), RawMarketplaceManifestPluginSource::Object( RawMarketplaceManifestPluginSourceObject::Npm { package, version, registry, }, ) => Ok(MarketplacePluginSource::Npm { package: normalize_npm_package(marketplace_path, &package)?, version: normalize_optional_npm_version(marketplace_path, version)?, registry: normalize_optional_npm_registry(marketplace_path, registry)?, }), RawMarketplaceManifestPluginSource::Unsupported(_) => { unreachable!("unsupported plugin sources should be filtered before resolution") } } } fn resolve_local_plugin_source_path( marketplace_path: &AbsolutePathBuf, path: &str, ) -> Result { match path { "" => { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "local plugin source path must not be empty".to_string(), }); } "." | "./" => return marketplace_root_dir(marketplace_path), _ => {} } // Non-root local sources must keep the explicit `./` prefix and remain normalized. let relative_path = path.strip_prefix("./").or_else(|| { marketplace_path .as_path() .ends_with(".cursor-plugin/marketplace.json") .then_some(path) }); let Some(relative_path) = relative_path else { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "local plugin source path must start with `./`".to_string(), }); }; let relative_source_path = Path::new(relative_path); if relative_source_path .components() .any(|component| !matches!(component, Component::Normal(_))) { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "local plugin source path must stay within the marketplace root".to_string(), }); } // `marketplace.json` lives under a supported marketplace layout beneath ``, // but local plugin paths are resolved relative to ``. Ok(marketplace_root_dir(marketplace_path)?.join(relative_source_path)) } fn normalize_remote_plugin_subdir( marketplace_path: &AbsolutePathBuf, path: &str, ) -> Result { let path = path.trim(); let path = path.strip_prefix("./").unwrap_or(path); if path.is_empty() { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "git plugin source path must not be empty".to_string(), }); } let relative_path = Path::new(path); if relative_path .components() .any(|component| !matches!(component, Component::Normal(_))) { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "git plugin source path must stay within the repository root".to_string(), }); } Ok(path.to_string()) } fn normalize_git_plugin_source_url( marketplace_path: &AbsolutePathBuf, url: &str, ) -> Result { let url = url.trim(); if url.is_empty() { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "git plugin source url must not be empty".to_string(), }); } if url.starts_with("http://") || url.starts_with("https://") { return Ok(normalize_github_git_url(url)); } if url.starts_with("./") || url.starts_with("../") || url.starts_with(".\\") || url.starts_with("..\\") { return normalize_relative_git_plugin_source_url(marketplace_path, url); } if url.starts_with("file://") || url.starts_with('/') { return Ok(url.to_string()); } if url.starts_with("ssh://") || url.starts_with("git@") && url.contains(':') { return Ok(url.to_string()); } if let Some(url) = normalize_github_shorthand_url(url) { return Ok(url); } Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: format!("invalid git plugin source url: {url}"), }) } fn normalize_relative_git_plugin_source_url( marketplace_path: &AbsolutePathBuf, url: &str, ) -> Result { let mut normalized = marketplace_root_dir(marketplace_path)? .as_path() .to_path_buf(); for segment in url.split(['/', '\\']) { match segment { "" | "." => {} ".." => { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "relative git plugin source url must stay within the marketplace root" .to_string(), }); } segment => normalized.push(segment), } } Ok(normalized.display().to_string()) } fn normalize_optional_git_selector(value: &Option) -> Option { value .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) } fn normalize_npm_package( marketplace_path: &AbsolutePathBuf, package: &str, ) -> Result { let package = package.trim(); let package_scope = if package.starts_with('@') { NpmPackageScope::Scoped } else { NpmPackageScope::Unscoped }; let segments = if let Some(scoped_package) = package.strip_prefix('@') { scoped_package.split('/').collect::>() } else { package.split('/').collect::>() }; let expected_segments = match package_scope { NpmPackageScope::Scoped => 2, NpmPackageScope::Unscoped => 1, }; if package.is_empty() || segments.len() != expected_segments || segments .iter() .any(|segment| !is_valid_npm_package_segment(segment, package_scope)) { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: format!("invalid npm plugin source package: {package}"), }); } Ok(package.to_string()) } fn is_valid_npm_package_segment(segment: &str, package_scope: NpmPackageScope) -> bool { !segment.is_empty() && segment != "." && segment != ".." && (package_scope == NpmPackageScope::Scoped || !matches!(segment.chars().next(), Some('.' | '_'))) && segment .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) } fn normalize_optional_npm_version( marketplace_path: &AbsolutePathBuf, version: Option, ) -> Result, MarketplaceError> { let Some(version) = normalize_optional_npm_source_field(marketplace_path, version, "version")? else { return Ok(None); }; if !is_registry_npm_version_selector(&version) { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: format!("npm plugin source version must use the registry: {version}"), }); } Ok(Some(version)) } fn is_registry_npm_version_selector(version: &str) -> bool { version != "." && version != ".." && !version.chars().any(|ch| matches!(ch, '/' | '\\' | ':')) } fn normalize_optional_npm_registry( marketplace_path: &AbsolutePathBuf, registry: Option, ) -> Result, MarketplaceError> { let Some(registry) = normalize_optional_npm_source_field(marketplace_path, registry, "registry")? else { return Ok(None); }; let parsed = url::Url::parse(®istry).map_err(|_| MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: format!("invalid npm plugin source registry: {registry}"), })?; if parsed.scheme() != "https" || parsed.host_str().is_none() || !parsed.username().is_empty() || parsed.password().is_some() || parsed.query().is_some() || parsed.fragment().is_some() { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: format!("invalid npm plugin source registry: {registry}"), }); } Ok(Some(registry)) } fn normalize_optional_npm_source_field( marketplace_path: &AbsolutePathBuf, value: Option, field: &str, ) -> Result, MarketplaceError> { let Some(value) = value else { return Ok(None); }; let value = value.trim(); if value.is_empty() { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: format!("npm plugin source {field} must not be empty"), }); } Ok(Some(value.to_string())) } fn normalize_github_git_url(url: &str) -> String { if url.starts_with("https://github.com/") && !url.ends_with(".git") { format!("{url}.git") } else { url.to_string() } } fn normalize_github_shorthand_url(source: &str) -> Option { if !looks_like_github_shorthand(source) { return None; } let mut segments = source.split('/'); let owner = segments.next()?; let repo = segments.next()?; let repo = repo.strip_suffix(".git").unwrap_or(repo); if repo.is_empty() { return None; } Some(format!("https://github.com/{owner}/{repo}.git")) } fn looks_like_github_shorthand(source: &str) -> bool { let mut segments = source.split('/'); let owner = segments.next(); let repo = segments.next(); let extra = segments.next(); owner.is_some_and(is_github_shorthand_segment) && repo.is_some_and(is_github_shorthand_segment) && extra.is_none() } fn is_github_shorthand_segment(segment: &str) -> bool { !segment.is_empty() && segment .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) } pub fn plugin_interface_with_marketplace_category( mut interface: Option, category: Option, ) -> Option { if let Some(category) = category { // Marketplace taxonomy wins when both sources provide a category. interface .get_or_insert_with(PluginManifestInterface::default) .category = Some(category); } interface } #[doc(hidden)] pub fn marketplace_root_dir( marketplace_path: &AbsolutePathBuf, ) -> Result { for relative_path in MARKETPLACE_MANIFEST_RELATIVE_PATHS { if let Some(marketplace_root) = marketplace_root_from_layout(marketplace_path.as_path(), relative_path) { return AbsolutePathBuf::try_from(marketplace_root) .map_err(|_| invalid_marketplace_layout_error(marketplace_path)); } } Err(invalid_marketplace_layout_error(marketplace_path)) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RawMarketplaceManifest { name: String, #[serde(default)] interface: Option, plugins: Vec, } #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct RawMarketplaceManifestInterface { #[serde(default)] display_name: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RawMarketplaceManifestPlugin { name: String, source: RawMarketplaceManifestPluginSource, #[serde(default)] policy: RawMarketplaceManifestPluginPolicy, #[serde(default)] category: Option, #[serde(default)] #[serde(flatten)] manifest_fields: JsonMap, } #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] struct RawMarketplaceManifestPluginPolicy { #[serde(default)] installation: MarketplacePluginInstallPolicy, #[serde(default)] authentication: MarketplacePluginAuthPolicy, products: Option>, } #[derive(Debug, Deserialize)] #[serde(untagged)] enum RawMarketplaceManifestPluginSource { Path(String), Object(RawMarketplaceManifestPluginSourceObject), #[allow(dead_code)] Unsupported(JsonValue), } #[derive(Debug, Deserialize)] #[serde(tag = "source", rename_all = "lowercase")] enum RawMarketplaceManifestPluginSourceObject { Local { path: String, }, Url { url: String, path: Option, #[serde(rename = "ref")] ref_name: Option, sha: Option, }, #[serde(rename = "git-subdir")] GitSubdir { url: String, path: String, #[serde(rename = "ref")] ref_name: Option, sha: Option, }, Npm { package: String, version: Option, registry: Option, }, } fn resolve_marketplace_interface( interface: Option, ) -> Option { let interface = interface?; if interface.display_name.is_some() { Some(MarketplaceInterface { display_name: interface.display_name, }) } else { None } } fn fallback_plugin_manifest_path(plugin_root: &Path) -> PathBuf { plugin_root.join(".codex-plugin/plugin.json") } fn marketplace_plugin_manifest_fallback( name: &str, category: Option<&str>, manifest_fields: &JsonMap, ) -> MarketplacePluginManifestFallback { let mut manifest = manifest_fields.clone(); manifest.insert("name".to_string(), JsonValue::String(name.to_string())); if let Some(category) = category { manifest.insert( "category".to_string(), JsonValue::String(category.to_string()), ); } if let Some(interface) = plugin_manifest_interface(manifest_fields, category) { manifest.insert("interface".to_string(), interface); } let contents = serde_json::to_string_pretty(&JsonValue::Object(manifest)) .unwrap_or_else(|_| format!(r#"{{"name":"{name}"}}"#)); MarketplacePluginManifestFallback { contents, has_metadata: !manifest_fields.is_empty() || category.is_some(), } } fn plugin_manifest_interface( fields: &JsonMap, category: Option<&str>, ) -> Option { let mut interface = fields .get("interface") .and_then(JsonValue::as_object) .cloned() .unwrap_or_default(); if !interface.contains_key("displayName") && let Some(display_name) = fields.get("displayName").and_then(JsonValue::as_str) { interface.insert( "displayName".to_string(), JsonValue::String(display_name.to_string()), ); } if !interface.contains_key("developerName") && let Some(author_name) = fields .get("author") .and_then(|author| author.get("name")) .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { interface.insert( "developerName".to_string(), JsonValue::String(author_name.to_string()), ); } if !interface.contains_key("websiteUrl") && !interface.contains_key("websiteURL") && let Some(homepage) = fields.get("homepage").and_then(JsonValue::as_str) { interface.insert( "websiteUrl".to_string(), JsonValue::String(homepage.to_string()), ); } if let Some(category) = category.map(str::trim).filter(|value| !value.is_empty()) { interface.insert( "category".to_string(), JsonValue::String(category.to_string()), ); } (!interface.is_empty()).then_some(JsonValue::Object(interface)) } #[cfg(test)] #[path = "marketplace_tests.rs"] mod tests;