mirror of
https://github.com/openai/codex.git
synced 2026-10-24 18:18:41 +00:00
## What changed - Detect and import supported Cursor settings, sandbox permissions, MCP servers, project instructions, hooks, agents, commands, plugins, and recent chat sessions. - Update the `/import` flow to check Claude Code and Cursor, prompting for a source when both have importable data. - Add `migrationSource` to the app-server detect and import requests so each operation uses the selected adapter, while retaining `source` for import attribution and backwards compatibility. ## Testing - Add coverage for Cursor config conversion, hooks, plugins, session discovery and conversion, app-server imports, and TUI source selection. GitOrigin-RevId: a65a1511b6c1c2f9e3e0cfe4f816bb435807282e
1140 lines
36 KiB
Rust
1140 lines
36 KiB
Rust
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<PluginManifestInterface>,
|
|
pub manifest: Option<crate::manifest::PluginManifest>,
|
|
pub manifest_fallback: MarketplacePluginManifestFallback,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Marketplace {
|
|
pub name: String,
|
|
pub path: AbsolutePathBuf,
|
|
pub interface: Option<MarketplaceInterface>,
|
|
pub plugins: Vec<MarketplacePlugin>,
|
|
}
|
|
|
|
#[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<Marketplace>,
|
|
pub errors: Vec<MarketplaceListError>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct MarketplaceInterface {
|
|
pub display_name: Option<String>,
|
|
}
|
|
|
|
#[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::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)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
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>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
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>,
|
|
},
|
|
}
|
|
|
|
#[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<Vec<Product>>,
|
|
}
|
|
|
|
#[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<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,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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<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,
|
|
})
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
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
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
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))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RawMarketplaceManifest {
|
|
name: String,
|
|
#[serde(default)]
|
|
interface: Option<RawMarketplaceManifestInterface>,
|
|
plugins: Vec<RawMarketplaceManifestPlugin>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RawMarketplaceManifestInterface {
|
|
#[serde(default)]
|
|
display_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RawMarketplaceManifestPlugin {
|
|
name: String,
|
|
source: RawMarketplaceManifestPluginSource,
|
|
#[serde(default)]
|
|
policy: RawMarketplaceManifestPluginPolicy,
|
|
#[serde(default)]
|
|
category: Option<String>,
|
|
#[serde(default)]
|
|
#[serde(flatten)]
|
|
manifest_fields: JsonMap<String, JsonValue>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RawMarketplaceManifestPluginPolicy {
|
|
#[serde(default)]
|
|
installation: MarketplacePluginInstallPolicy,
|
|
#[serde(default)]
|
|
authentication: MarketplacePluginAuthPolicy,
|
|
products: Option<Vec<Product>>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[serde(rename = "ref")]
|
|
ref_name: Option<String>,
|
|
sha: Option<String>,
|
|
},
|
|
#[serde(rename = "git-subdir")]
|
|
GitSubdir {
|
|
url: String,
|
|
path: String,
|
|
#[serde(rename = "ref")]
|
|
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))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "marketplace_tests.rs"]
|
|
mod tests;
|