Download codex-rs/core-plugins/src/marketplace.rs from SaylorTwift/codex: direct link, hf CLI and curl.
- Browser
- Download file 36.8 kB
-
https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/core-plugins/src/marketplace.rs
- Command line
-
hf download hf://SaylorTwift/codex/codex-rs/core-plugins/src/marketplace.rs
-
curl -L -o marketplace.rs https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/core-plugins/src/marketplace.rs
36.8 kB
| 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", | |
| ]; | |
| pub struct ResolvedMarketplacePlugin { | |
| pub plugin_id: PluginId, | |
| pub source: MarketplacePluginSource, | |
| pub policy: MarketplacePluginPolicy, | |
| pub interface: Option<PluginManifestInterface>, | |
| pub manifest: Option<crate::manifest::PluginManifest>, | |
| pub manifest_fallback: MarketplacePluginManifestFallback, | |
| } | |
| pub struct Marketplace { | |
| pub name: String, | |
| pub path: AbsolutePathBuf, | |
| pub interface: Option<MarketplaceInterface>, | |
| pub plugins: Vec<MarketplacePlugin>, | |
| } | |
| pub struct MarketplaceListError { | |
| pub path: AbsolutePathBuf, | |
| pub message: String, | |
| } | |
| pub struct MarketplaceListOutcome { | |
| pub marketplaces: Vec<Marketplace>, | |
| pub errors: Vec<MarketplaceListError>, | |
| } | |
| pub struct MarketplaceInterface { | |
| pub display_name: Option<String>, | |
| } | |
| 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::PluginManifest> { | |
| crate::manifest::parse_plugin_manifest( | |
| plugin_root, | |
| &fallback_plugin_manifest_path(plugin_root), | |
| &self.contents, | |
| ) | |
| .ok() | |
| } | |
| pub(crate) fn parse_for_listing(&self) -> Option<crate::manifest::PluginManifest> { | |
| // 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) | |
| } | |
| } | |
| pub struct MarketplacePlugin { | |
| pub name: String, | |
| pub local_version: Option<String>, | |
| pub source: MarketplacePluginSource, | |
| pub policy: MarketplacePluginPolicy, | |
| pub interface: Option<PluginManifestInterface>, | |
| pub keywords: Vec<String>, | |
| pub manifest_fallback: Option<MarketplacePluginManifestFallback>, | |
| } | |
| pub enum MarketplacePluginSource { | |
| Local { | |
| path: AbsolutePathBuf, | |
| }, | |
| Git { | |
| url: String, | |
| path: Option<String>, | |
| ref_name: Option<String>, | |
| sha: Option<String>, | |
| }, | |
| Npm { | |
| package: String, | |
| version: Option<String>, | |
| registry: Option<String>, | |
| }, | |
| } | |
| enum NpmPackageScope { | |
| Scoped, | |
| Unscoped, | |
| } | |
| impl MarketplacePluginSource { | |
| pub(crate) fn is_install_materialized(&self) -> bool { | |
| matches!(self, Self::Git { .. } | Self::Npm { .. }) | |
| } | |
| } | |
| 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<Vec<Product>>, | |
| } | |
| pub enum MarketplacePluginInstallPolicy { | |
| NotAvailable, | |
| Available, | |
| InstalledByDefault, | |
| } | |
| pub enum MarketplacePluginAuthPolicy { | |
| OnInstall, | |
| OnUse, | |
| } | |
| impl From<MarketplacePluginInstallPolicy> for PluginInstallPolicy { | |
| fn from(value: MarketplacePluginInstallPolicy) -> Self { | |
| match value { | |
| MarketplacePluginInstallPolicy::NotAvailable => Self::NotAvailable, | |
| MarketplacePluginInstallPolicy::Available => Self::Available, | |
| MarketplacePluginInstallPolicy::InstalledByDefault => Self::InstalledByDefault, | |
| } | |
| } | |
| } | |
| impl From<MarketplacePluginAuthPolicy> for PluginAuthPolicy { | |
| fn from(value: MarketplacePluginAuthPolicy) -> Self { | |
| match value { | |
| MarketplacePluginAuthPolicy::OnInstall => Self::OnInstall, | |
| MarketplacePluginAuthPolicy::OnUse => Self::OnUse, | |
| } | |
| } | |
| } | |
| pub enum MarketplaceError { | |
| Io { | |
| context: &'static str, | |
| source: io::Error, | |
| }, | |
| MarketplaceNotFound { path: PathBuf }, | |
| InvalidMarketplaceFile { path: PathBuf, message: String }, | |
| PluginNotFound { | |
| plugin_name: String, | |
| marketplace_name: String, | |
| }, | |
| PluginNotAvailable { | |
| plugin_name: String, | |
| marketplace_name: String, | |
| }, | |
| PluginsDisabled, | |
| 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<ResolvedMarketplacePlugin, MarketplaceError> { | |
| 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<Product>, | |
| ) -> Result<ResolvedMarketplacePlugin, MarketplaceError> { | |
| 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<MarketplaceListOutcome, MarketplaceError> { | |
| list_marketplaces_with_home(additional_roots, home_dir().as_deref()) | |
| } | |
| pub(crate) fn home_dir() -> Option<PathBuf> { | |
| ["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<String, MarketplaceError> { | |
| 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<AbsolutePathBuf> { | |
| 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<AbsolutePathBuf> { | |
| 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<PathBuf> { | |
| 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<Marketplace, MarketplaceError> { | |
| 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, | |
| }) | |
| } | |
| pub fn list_marketplaces_with_home( | |
| additional_roots: &[AbsolutePathBuf], | |
| home_dir: Option<&Path>, | |
| ) -> Result<MarketplaceListOutcome, MarketplaceError> { | |
| 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<AbsolutePathBuf> { | |
| 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<RawMarketplaceManifest, MarketplaceError> { | |
| 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<Option<ResolvedMarketplacePlugin>, 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<MarketplacePluginSource> { | |
| 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<MarketplacePluginSource, MarketplaceError> { | |
| 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<AbsolutePathBuf, MarketplaceError> { | |
| 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 `<root>`, | |
| // but local plugin paths are resolved relative to `<root>`. | |
| Ok(marketplace_root_dir(marketplace_path)?.join(relative_source_path)) | |
| } | |
| fn normalize_remote_plugin_subdir( | |
| marketplace_path: &AbsolutePathBuf, | |
| path: &str, | |
| ) -> Result<String, MarketplaceError> { | |
| 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<String, MarketplaceError> { | |
| 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<String, MarketplaceError> { | |
| 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<String>) -> Option<String> { | |
| 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<String, MarketplaceError> { | |
| 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::<Vec<_>>() | |
| } else { | |
| package.split('/').collect::<Vec<_>>() | |
| }; | |
| 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<String>, | |
| ) -> Result<Option<String>, 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<String>, | |
| ) -> Result<Option<String>, 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<String>, | |
| field: &str, | |
| ) -> Result<Option<String>, 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<String> { | |
| 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<PluginManifestInterface>, | |
| category: Option<String>, | |
| ) -> Option<PluginManifestInterface> { | |
| 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 | |
| } | |
| pub fn marketplace_root_dir( | |
| marketplace_path: &AbsolutePathBuf, | |
| ) -> Result<AbsolutePathBuf, MarketplaceError> { | |
| 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)) | |
| } | |
| struct RawMarketplaceManifest { | |
| name: String, | |
| interface: Option<RawMarketplaceManifestInterface>, | |
| plugins: Vec<RawMarketplaceManifestPlugin>, | |
| } | |
| struct RawMarketplaceManifestInterface { | |
| display_name: Option<String>, | |
| } | |
| struct RawMarketplaceManifestPlugin { | |
| name: String, | |
| source: RawMarketplaceManifestPluginSource, | |
| policy: RawMarketplaceManifestPluginPolicy, | |
| category: Option<String>, | |
| manifest_fields: JsonMap<String, JsonValue>, | |
| } | |
| struct RawMarketplaceManifestPluginPolicy { | |
| installation: MarketplacePluginInstallPolicy, | |
| authentication: MarketplacePluginAuthPolicy, | |
| products: Option<Vec<Product>>, | |
| } | |
| enum RawMarketplaceManifestPluginSource { | |
| Path(String), | |
| Object(RawMarketplaceManifestPluginSourceObject), | |
| Unsupported(JsonValue), | |
| } | |
| enum RawMarketplaceManifestPluginSourceObject { | |
| Local { | |
| path: String, | |
| }, | |
| Url { | |
| url: String, | |
| path: Option<String>, | |
| ref_name: Option<String>, | |
| sha: Option<String>, | |
| }, | |
| GitSubdir { | |
| url: String, | |
| path: String, | |
| ref_name: Option<String>, | |
| sha: Option<String>, | |
| }, | |
| Npm { | |
| package: String, | |
| version: Option<String>, | |
| registry: Option<String>, | |
| }, | |
| } | |
| fn resolve_marketplace_interface( | |
| interface: Option<RawMarketplaceManifestInterface>, | |
| ) -> Option<MarketplaceInterface> { | |
| 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<String, JsonValue>, | |
| ) -> 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<String, JsonValue>, | |
| category: Option<&str>, | |
| ) -> Option<JsonValue> { | |
| 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)) | |
| } | |
| mod tests; | |