use anyhow::Context; use anyhow::Result; use anyhow::bail; use clap::Parser; use codex_app_server_protocol::AuthMode; use codex_core::config::Config; use codex_core::config::find_codex_home; use codex_core_plugins::ConfiguredMarketplace; use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; use codex_core_plugins::PluginInstallOutcome; use codex_core_plugins::PluginInstallRequest; use codex_core_plugins::PluginsConfigInput; use codex_core_plugins::PluginsManager; use codex_core_plugins::installed_marketplaces::marketplace_install_root; use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root; use codex_core_plugins::marketplace::MarketplaceListError; use codex_core_plugins::marketplace::MarketplacePluginAuthPolicy; use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy; use codex_core_plugins::marketplace::MarketplacePluginSource; use codex_core_plugins::marketplace::find_marketplace_manifest_path; use codex_login::CodexAuth; use codex_login::auth::read_codex_api_key_from_env; use codex_plugin::PluginId; use codex_plugin::validate_plugin_segment; use codex_utils_cli::CliConfigOverrides; use serde::Serialize; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; use crate::marketplace_cmd::MarketplaceCli; const OPENAI_BUNDLED_ALPHA_MARKETPLACE_NAME: &str = "openai-bundled-alpha"; const OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME: &str = "openai-primary-runtime"; #[derive(Debug, Parser)] #[command(bin_name = "codex plugin")] pub struct PluginCli { #[clap(flatten)] pub config_overrides: CliConfigOverrides, #[command(subcommand)] pub subcommand: PluginSubcommand, } #[derive(Debug, clap::Subcommand)] pub enum PluginSubcommand { /// Install a plugin from a configured marketplace snapshot. /// /// Pass either `PLUGIN@MARKETPLACE` or pass `PLUGIN` with /// `--marketplace MARKETPLACE`. Add(AddPluginArgs), /// List plugins available from configured marketplace snapshots. List(ListPluginsArgs), /// Add, list, upgrade, or remove configured plugin marketplaces. Marketplace(MarketplaceCli), /// Remove an installed plugin from local config and cache. /// /// Pass either `PLUGIN@MARKETPLACE` or pass `PLUGIN` with /// `--marketplace MARKETPLACE`. Remove(RemovePluginArgs), } #[derive(Debug, Parser)] #[command( bin_name = "codex plugin add", after_help = "Examples:\n codex plugin add sample@debug\n codex plugin add sample --marketplace debug" )] pub struct AddPluginArgs { /// Plugin selector to install: either PLUGIN@MARKETPLACE or PLUGIN with --marketplace. #[arg(value_name = "PLUGIN[@MARKETPLACE]")] plugin: String, /// Configured marketplace name to use when PLUGIN does not include @MARKETPLACE. #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] marketplace_name: Option, /// Output install result as JSON. #[arg(long = "json")] json: bool, } #[derive(Debug, Parser)] #[command( bin_name = "codex plugin list", after_help = "Examples:\n codex plugin list\n codex plugin list --marketplace debug\n codex plugin list --json\n codex plugin list --available --json" )] pub struct ListPluginsArgs { /// Only list plugins from this configured marketplace name. #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] marketplace_name: Option, /// Output plugin list as JSON. #[arg(long = "json")] json: bool, /// Include uninstalled marketplace plugins in the JSON output. #[arg(long = "available", requires = "json")] available: bool, } #[derive(Debug, Parser)] #[command( bin_name = "codex plugin remove", after_help = "Examples:\n codex plugin remove sample@debug\n codex plugin remove sample --marketplace debug" )] pub struct RemovePluginArgs { /// Plugin selector to remove: either PLUGIN@MARKETPLACE or PLUGIN with --marketplace. #[arg(value_name = "PLUGIN[@MARKETPLACE]")] plugin: String, /// Marketplace name to use when PLUGIN does not include @MARKETPLACE. #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] marketplace_name: Option, /// Output remove result as JSON. #[arg(long = "json")] json: bool, } pub async fn run_plugin_add( overrides: Vec<(String, toml::Value)>, args: AddPluginArgs, ) -> Result<()> { let PluginCommandContext { codex_home, plugins_input, manager, } = load_plugin_command_context(overrides).await?; let AddPluginArgs { plugin, marketplace_name, json, } = args; let PluginSelection { plugin_name, marketplace_name, .. } = parse_plugin_selection(plugin, marketplace_name)?; let marketplace = find_marketplace_for_plugin( &manager, codex_home.as_path(), &plugins_input, &marketplace_name, &plugin_name, )?; let outcome = manager .install_plugin(PluginInstallRequest { plugin_name, marketplace_path: marketplace.path, }) .await?; if json { let output = JsonPluginAddOutput::from_outcome(outcome); println!("{}", serde_json::to_string_pretty(&output)?); return Ok(()); } println!( "Added plugin `{}` from marketplace `{}`.", outcome.plugin_id.plugin_name, outcome.plugin_id.marketplace_name ); println!( "Installed plugin root: {}", outcome.installed_path.as_path().display() ); Ok(()) } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct JsonPluginAddOutput { plugin_id: String, name: String, marketplace_name: String, version: String, installed_path: String, auth_policy: &'static str, } impl JsonPluginAddOutput { fn from_outcome(outcome: PluginInstallOutcome) -> Self { Self { plugin_id: outcome.plugin_id.as_key(), name: outcome.plugin_id.plugin_name, marketplace_name: outcome.plugin_id.marketplace_name, version: outcome.plugin_version, installed_path: outcome.installed_path.as_path().display().to_string(), auth_policy: auth_policy_label(outcome.auth_policy), } } } pub async fn run_plugin_list( overrides: Vec<(String, toml::Value)>, args: ListPluginsArgs, ) -> Result<()> { let PluginCommandContext { codex_home, plugins_input, manager, .. } = load_plugin_command_context(overrides).await?; let outcome = manager .list_marketplaces_for_config(&plugins_input, &[], /*include_openai_curated*/ true) .context("failed to list marketplace plugins")?; ensure_configured_marketplace_snapshots_loaded( codex_home.as_path(), &plugins_input, &outcome.errors, /*marketplace_name*/ None, )?; let marketplaces = outcome .marketplaces .into_iter() .filter(|marketplace| { args.marketplace_name .as_ref() .is_none_or(|name| marketplace.name == *name) }) .collect::>(); let marketplace_sources = configured_marketplace_sources(&plugins_input); if args.json { let output = JsonPluginListOutput::from_marketplaces( marketplaces, args.available, &marketplace_sources, ); println!("{}", serde_json::to_string_pretty(&output)?); return Ok(()); } if marketplaces.is_empty() { if let Some(marketplace_name) = args.marketplace_name { println!("No plugins found in marketplace `{marketplace_name}`."); } else { println!("No marketplace plugins found."); } } else { for (index, marketplace) in marketplaces.into_iter().enumerate() { let mut rows = Vec::new(); let mut plugin_width = "PLUGIN".len(); let mut status_width = "STATUS".len(); let mut installed_version_width = "VERSION".len(); let mut path_width = "PATH".len(); for plugin in &marketplace.plugins { let state = if plugin.installed && plugin.enabled { "installed, enabled" } else if plugin.installed { "installed, disabled" } else { "not installed" }; let installed_version = plugin.installed_version.clone().unwrap_or_default(); let path = match &plugin.source { codex_core_plugins::marketplace::MarketplacePluginSource::Local { path } => { path.as_path().display().to_string() } codex_core_plugins::marketplace::MarketplacePluginSource::Git { url, path, ref_name, sha, } => { let mut parts = vec![url.clone()]; if let Some(path) = path { parts.push(format!("path `{path}`")); } if let Some(ref_name) = ref_name { parts.push(format!("ref `{ref_name}`")); } if let Some(sha) = sha { parts.push(format!("sha `{sha}`")); } parts.join(", ") } }; plugin_width = plugin_width.max(plugin.id.len()); status_width = status_width.max(state.len()); installed_version_width = installed_version_width.max(installed_version.len()); path_width = path_width.max(path.len()); rows.push((plugin.id.clone(), state, installed_version, path)); } if index > 0 { println!(); } println!("Marketplace `{}`", marketplace.name); println!("{}", marketplace.path.as_path().display()); println!(); println!( "{:, available: Vec, } impl JsonPluginListOutput { fn from_marketplaces( marketplaces: Vec, include_available: bool, marketplace_sources: &HashMap, ) -> Self { let mut installed = Vec::new(); let mut available = Vec::new(); for marketplace in marketplaces { let marketplace_source = marketplace_sources.get(&marketplace.name).cloned(); for plugin in marketplace.plugins { let entry = JsonPluginListEntry::from_configured_plugin( &marketplace.name, marketplace_source.clone(), plugin, ); if entry.installed { installed.push(entry); } else if include_available { available.push(entry); } } } Self { installed, available, } } } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct JsonPluginListEntry { plugin_id: String, name: String, marketplace_name: String, version: Option, installed: bool, enabled: bool, source: JsonPluginSource, #[serde(skip_serializing_if = "Option::is_none")] marketplace_source: Option, install_policy: &'static str, auth_policy: &'static str, } impl JsonPluginListEntry { fn from_configured_plugin( marketplace_name: &str, marketplace_source: Option, plugin: codex_core_plugins::ConfiguredMarketplacePlugin, ) -> Self { let version = plugin.installed_version.or(plugin.local_version); Self { plugin_id: plugin.id, name: plugin.name, marketplace_name: marketplace_name.to_string(), version, installed: plugin.installed, enabled: plugin.enabled, source: JsonPluginSource::from_marketplace_source(plugin.source), marketplace_source, install_policy: install_policy_label(plugin.policy.installation), auth_policy: auth_policy_label(plugin.policy.authentication), } } } #[derive(Debug, Serialize)] #[serde(tag = "source", rename_all = "kebab-case")] enum JsonPluginSource { Local { path: String, }, Git { url: String, #[serde(rename = "ref", skip_serializing_if = "Option::is_none")] ref_name: Option, #[serde(skip_serializing_if = "Option::is_none")] sha: Option, }, GitSubdir { url: String, path: String, #[serde(rename = "ref", skip_serializing_if = "Option::is_none")] ref_name: Option, #[serde(skip_serializing_if = "Option::is_none")] sha: Option, }, } impl JsonPluginSource { fn from_marketplace_source(source: MarketplacePluginSource) -> Self { match source { MarketplacePluginSource::Local { path } => Self::Local { path: path.as_path().display().to_string(), }, MarketplacePluginSource::Git { url, path: Some(path), ref_name, sha, } => Self::GitSubdir { url, path, ref_name, sha, }, MarketplacePluginSource::Git { url, path: None, ref_name, sha, } => Self::Git { url, ref_name, sha }, } } } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct JsonMarketplaceSource { source_type: String, source: String, } pub(crate) fn configured_marketplace_sources( plugins_input: &PluginsConfigInput, ) -> HashMap { let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else { return HashMap::new(); }; let Some(marketplaces) = user_config .get("marketplaces") .and_then(toml::Value::as_table) else { return HashMap::new(); }; marketplaces .iter() .filter_map(|(marketplace_name, marketplace)| { let source_type = marketplace .get("source_type") .and_then(toml::Value::as_str)?; let source = marketplace.get("source").and_then(toml::Value::as_str)?; Some(( marketplace_name.clone(), JsonMarketplaceSource { source_type: source_type.to_string(), source: source.to_string(), }, )) }) .collect() } fn install_policy_label(policy: MarketplacePluginInstallPolicy) -> &'static str { match policy { MarketplacePluginInstallPolicy::NotAvailable => "NOT_AVAILABLE", MarketplacePluginInstallPolicy::Available => "AVAILABLE", MarketplacePluginInstallPolicy::InstalledByDefault => "INSTALLED_BY_DEFAULT", } } fn auth_policy_label(policy: MarketplacePluginAuthPolicy) -> &'static str { match policy { MarketplacePluginAuthPolicy::OnInstall => "ON_INSTALL", MarketplacePluginAuthPolicy::OnUse => "ON_USE", } } pub async fn run_plugin_remove( overrides: Vec<(String, toml::Value)>, args: RemovePluginArgs, ) -> Result<()> { let PluginCommandContext { manager, .. } = load_plugin_command_context(overrides).await?; let RemovePluginArgs { plugin, marketplace_name, json, } = args; let selection = parse_plugin_selection(plugin, marketplace_name)?; manager .uninstall_plugin(selection.plugin_key.clone()) .await?; if json { let output = JsonPluginRemoveOutput::from_selection(selection); println!("{}", serde_json::to_string_pretty(&output)?); return Ok(()); } println!( "Removed plugin `{}` from marketplace `{}`.", selection.plugin_name, selection.marketplace_name ); Ok(()) } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct JsonPluginRemoveOutput { plugin_id: String, name: String, marketplace_name: String, } impl JsonPluginRemoveOutput { fn from_selection(selection: PluginSelection) -> Self { Self { plugin_id: selection.plugin_key, name: selection.plugin_name, marketplace_name: selection.marketplace_name, } } } struct PluginCommandContext { codex_home: PathBuf, plugins_input: PluginsConfigInput, manager: PluginsManager, } async fn load_plugin_command_context( overrides: Vec<(String, toml::Value)>, ) -> Result { let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; let config = Config::load_with_cli_overrides(overrides) .await .context("failed to load configuration")?; let plugins_input = config.plugins_config_input(); let manager = PluginsManager::new(codex_home.to_path_buf()); manager.set_auth_mode(load_cli_auth_mode(&config).await); Ok(PluginCommandContext { codex_home: codex_home.to_path_buf(), plugins_input, manager, }) } pub(crate) async fn load_cli_auth_mode(config: &Config) -> Option { if let Some(api_key) = read_codex_api_key_from_env() { return Some(CodexAuth::from_api_key(&api_key).api_auth_mode()); } CodexAuth::from_auth_storage( &config.codex_home, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), config.auth_keyring_backend_kind(), ) .await .ok() .flatten() .map(|auth| auth.api_auth_mode()) } struct PluginSelection { plugin_name: String, marketplace_name: String, plugin_key: String, } impl PluginSelection { fn from_plugin_id(plugin_id: PluginId) -> Self { let plugin_key = plugin_id.as_key(); Self { plugin_name: plugin_id.plugin_name, marketplace_name: plugin_id.marketplace_name, plugin_key, } } } fn parse_plugin_selection( plugin: String, marketplace_name: Option, ) -> Result { match (PluginId::parse(&plugin), marketplace_name) { (Ok(plugin_id), None) => Ok(PluginSelection::from_plugin_id(plugin_id)), (Ok(plugin_id), Some(marketplace_name)) => { if plugin_id.marketplace_name != marketplace_name { bail!( "plugin id `{}` belongs to marketplace `{}`, but --marketplace specified `{}`", plugin, plugin_id.marketplace_name, marketplace_name ); } Ok(PluginSelection::from_plugin_id(plugin_id)) } (Err(_), Some(marketplace_name)) => Ok(PluginSelection::from_plugin_id(PluginId::new( plugin, marketplace_name, )?)), (Err(_), None) => { bail!("plugin requires --marketplace unless passed as @") } } } fn find_marketplace_for_plugin( manager: &PluginsManager, codex_home: &std::path::Path, plugins_input: &PluginsConfigInput, marketplace_name: &str, plugin_name: &str, ) -> Result { let outcome = manager .list_marketplaces_for_config(plugins_input, &[], /*include_openai_curated*/ true) .context("failed to list marketplace plugins")?; ensure_configured_marketplace_snapshots_loaded( codex_home, plugins_input, &outcome.errors, Some(marketplace_name), )?; let matches = outcome .marketplaces .into_iter() .filter(|marketplace| marketplace.name == marketplace_name) .filter(|marketplace| { marketplace .plugins .iter() .any(|plugin| plugin.name == plugin_name) }) .collect::>(); match matches.as_slice() { [] => bail!("plugin `{plugin_name}` was not found in marketplace `{marketplace_name}`"), [marketplace] => Ok(marketplace.clone()), _ => bail!( "plugin `{plugin_name}` in marketplace `{marketplace_name}` matched multiple marketplace roots" ), } } pub(crate) struct ConfiguredMarketplaceSnapshotIssue { pub(crate) marketplace_name: String, pub(crate) path: PathBuf, pub(crate) message: String, } fn ensure_configured_marketplace_snapshots_loaded( codex_home: &std::path::Path, plugins_input: &PluginsConfigInput, load_errors: &[MarketplaceListError], marketplace_name: Option<&str>, ) -> Result<()> { let issues = configured_marketplace_snapshot_issues( codex_home, plugins_input, load_errors, marketplace_name, ); if issues.is_empty() { return Ok(()); } let issue_lines = issues .iter() .map(|issue| { format!( "- `{}` at {}: {}", issue.marketplace_name, issue.path.display(), issue.message ) }) .collect::>() .join("\n"); bail!("failed to load configured marketplace snapshot(s):\n{issue_lines}"); } pub(crate) fn configured_marketplace_snapshot_issues( codex_home: &std::path::Path, plugins_input: &PluginsConfigInput, load_errors: &[MarketplaceListError], marketplace_name: Option<&str>, ) -> Vec { let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else { return Vec::new(); }; let Some(configured_marketplaces) = user_config .get("marketplaces") .and_then(toml::Value::as_table) else { return Vec::new(); }; let default_install_root = marketplace_install_root(codex_home); let mut manifest_paths = Vec::new(); let mut issues = Vec::new(); for (configured_name, marketplace) in configured_marketplaces { if marketplace_name.is_some_and(|name| configured_name != name) { continue; } if !marketplace.is_table() { issues.push(ConfiguredMarketplaceSnapshotIssue { marketplace_name: configured_name.clone(), path: PathBuf::from(""), message: "configured marketplace entry must be a table".to_string(), }); continue; } if let Err(err) = validate_plugin_segment(configured_name, "marketplace name") { issues.push(ConfiguredMarketplaceSnapshotIssue { marketplace_name: configured_name.clone(), path: PathBuf::from(""), message: err.to_string(), }); continue; } if marketplace.get("source_type").and_then(toml::Value::as_str) == Some("local") && marketplace .get("source") .and_then(toml::Value::as_str) .is_none_or(str::is_empty) { issues.push(ConfiguredMarketplaceSnapshotIssue { marketplace_name: configured_name.clone(), path: PathBuf::from(""), message: "configured local marketplace source is missing or empty".to_string(), }); continue; } let Some(root) = resolve_configured_marketplace_root( configured_name, marketplace, &default_install_root, ) else { continue; }; match find_marketplace_manifest_path(&root) { Some(path) => manifest_paths.push((configured_name.clone(), path)), None => { if is_implicit_system_marketplace_root(configured_name, codex_home, &root) { continue; } issues.push(ConfiguredMarketplaceSnapshotIssue { marketplace_name: configured_name.clone(), path: root, message: "marketplace root does not contain a supported manifest".to_string(), }); } } } for error in load_errors { if let Some((configured_name, _)) = manifest_paths .iter() .find(|(_, path)| path.as_path() == error.path.as_path()) { issues.push(ConfiguredMarketplaceSnapshotIssue { marketplace_name: configured_name.clone(), path: error.path.to_path_buf(), message: error.message.clone(), }); } } issues } fn is_implicit_system_marketplace_root( marketplace_name: &str, _codex_home: &Path, root: &Path, ) -> bool { if matches!( marketplace_name, OPENAI_BUNDLED_MARKETPLACE_NAME | OPENAI_BUNDLED_ALPHA_MARKETPLACE_NAME ) && path_ends_with(root, &[".tmp", "bundled-marketplaces", marketplace_name]) { return true; } marketplace_name == OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME && path_ends_with( root, &[ "codex-runtimes", "codex-primary-runtime", "plugins", marketplace_name, ], ) } fn path_ends_with(path: &Path, suffix: &[&str]) -> bool { let path_components = path .components() .map(|component| component.as_os_str().to_string_lossy().into_owned()) .collect::>(); path_components.as_slice().ends_with( &suffix .iter() .map(std::string::ToString::to_string) .collect::>(), ) }