feat(cli): add plugin marketplace commands

This commit is contained in:
Casey Chow
2026-05-06 14:39:54 -04:00
parent c7b55cdc46
commit 902ee48768
6 changed files with 568 additions and 17 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -2223,6 +2223,7 @@ dependencies = [
"codex-mcp-server",
"codex-memories-write",
"codex-models-manager",
"codex-plugin",
"codex-protocol",
"codex-responses-api-proxy",
"codex-rmcp-client",

View File

@@ -41,6 +41,7 @@ codex-memories-write = { workspace = true }
codex-mcp = { workspace = true }
codex-mcp-server = { workspace = true }
codex-models-manager = { workspace = true }
codex-plugin = { workspace = true }
codex-protocol = { workspace = true }
codex-responses-api-proxy = { workspace = true }
codex-rmcp-client = { workspace = true }

View File

@@ -48,11 +48,13 @@ mod app_cmd;
mod desktop_app;
mod marketplace_cmd;
mod mcp_cmd;
mod plugin_cmd;
#[cfg(not(windows))]
mod wsl_paths;
use crate::marketplace_cmd::MarketplaceCli;
use crate::mcp_cmd::McpCli;
use crate::plugin_cmd::PluginCli;
use crate::plugin_cmd::PluginSubcommand;
use codex_core::build_models_manager;
use codex_core::config::Config;
@@ -181,22 +183,6 @@ enum Subcommand {
Features(FeaturesCli),
}
#[derive(Debug, Parser)]
#[command(bin_name = "codex plugin")]
struct PluginCli {
#[clap(flatten)]
pub config_overrides: CliConfigOverrides,
#[command(subcommand)]
subcommand: PluginSubcommand,
}
#[derive(Debug, clap::Subcommand)]
enum PluginSubcommand {
/// Manage plugin marketplaces for Codex.
Marketplace(MarketplaceCli),
}
#[derive(Debug, Parser)]
struct CompletionCommand {
/// Shell to generate completions for
@@ -886,10 +872,28 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
} = plugin_cli;
prepend_config_flags(&mut config_overrides, root_config_overrides.clone());
match subcommand {
PluginSubcommand::Add(args) => {
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
plugin_cmd::run_plugin_add(overrides, args).await?;
}
PluginSubcommand::List(args) => {
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
plugin_cmd::run_plugin_list(overrides, args).await?;
}
PluginSubcommand::Marketplace(mut marketplace_cli) => {
prepend_config_flags(&mut marketplace_cli.config_overrides, config_overrides);
marketplace_cli.run().await?;
}
PluginSubcommand::Remove(args) => {
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
plugin_cmd::run_plugin_remove(overrides, args).await?;
}
}
}
Some(Subcommand::AppServer(app_server_cli)) => {
@@ -2040,6 +2044,7 @@ mod tests {
for (subcommand, usage) in [
("add", "Usage: codex plugin marketplace add"),
("list", "Usage: codex plugin marketplace list"),
("upgrade", "Usage: codex plugin marketplace upgrade"),
("remove", "Usage: codex plugin marketplace remove"),
] {
@@ -2066,6 +2071,45 @@ mod tests {
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
}
#[test]
fn plugin_add_parses_under_plugin() {
let cli = MultitoolCli::try_parse_from([
"codex",
"plugin",
"add",
"sample",
"--marketplace",
"debug",
])
.expect("parse");
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
}
#[test]
fn plugin_list_parses_under_plugin() {
let cli =
MultitoolCli::try_parse_from(["codex", "plugin", "list", "--marketplace", "debug"])
.expect("parse");
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
}
#[test]
fn plugin_remove_parses_under_plugin() {
let cli = MultitoolCli::try_parse_from([
"codex",
"plugin",
"remove",
"sample",
"--marketplace",
"debug",
])
.expect("parse");
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
}
#[test]
fn update_parses_as_update_subcommand() {
let cli = MultitoolCli::try_parse_from(["codex", "update"]).expect("parse");

View File

@@ -6,10 +6,13 @@ use codex_core::config::Config;
use codex_core::config::find_codex_home;
use codex_core_plugins::PluginMarketplaceUpgradeOutcome;
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_add::MarketplaceAddRequest;
use codex_core_plugins::marketplace_add::add_marketplace;
use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest;
use codex_core_plugins::marketplace_remove::remove_marketplace;
use codex_plugin::validate_plugin_segment;
use codex_utils_cli::CliConfigOverrides;
#[derive(Debug, Parser)]
@@ -25,6 +28,7 @@ pub struct MarketplaceCli {
#[derive(Debug, clap::Subcommand)]
enum MarketplaceSubcommand {
Add(AddMarketplaceArgs),
List,
Upgrade(UpgradeMarketplaceArgs),
Remove(RemoveMarketplaceArgs),
}
@@ -73,6 +77,7 @@ impl MarketplaceCli {
match subcommand {
MarketplaceSubcommand::Add(args) => run_add(args).await?,
MarketplaceSubcommand::List => run_list(overrides).await?,
MarketplaceSubcommand::Upgrade(args) => run_upgrade(overrides, args).await?,
MarketplaceSubcommand::Remove(args) => run_remove(args).await?,
}
@@ -118,6 +123,48 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> {
Ok(())
}
async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> {
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let configured_marketplaces = config
.config_layer_stack
.get_user_layer()
.and_then(|layer| layer.config.get("marketplaces"))
.and_then(toml::Value::as_table);
let Some(configured_marketplaces) = configured_marketplaces else {
println!("No configured plugin marketplaces.");
return Ok(());
};
if configured_marketplaces.is_empty() {
println!("No configured plugin marketplaces.");
return Ok(());
}
let default_install_root = marketplace_install_root(config.codex_home.as_path());
for (marketplace_name, marketplace) in configured_marketplaces {
if !marketplace.is_table() {
eprintln!("Ignoring invalid marketplace `{marketplace_name}`: expected table.");
continue;
}
if let Err(err) = validate_plugin_segment(marketplace_name, "marketplace name") {
eprintln!("Ignoring invalid marketplace `{marketplace_name}`: {err}.");
continue;
}
let root = resolve_configured_marketplace_root(
marketplace_name,
marketplace,
default_install_root.as_path(),
)
.map(|root| root.display().to_string())
.unwrap_or_else(|| "<invalid source>".to_string());
println!("{marketplace_name}\t{root}");
}
Ok(())
}
async fn run_upgrade(
overrides: Vec<(String, toml::Value)>,
args: UpgradeMarketplaceArgs,

View File

@@ -0,0 +1,280 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use clap::Parser;
use codex_core::config::Config;
use codex_core::config::find_codex_home;
use codex_core_plugins::ConfiguredMarketplace;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::PluginsConfigInput;
use codex_core_plugins::PluginsManager;
use codex_plugin::PluginId;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
use crate::marketplace_cmd::MarketplaceCli;
#[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 marketplace.
Add(AddPluginArgs),
/// List marketplace plugins.
List(ListPluginsArgs),
/// Manage plugin marketplaces for Codex.
Marketplace(MarketplaceCli),
/// Remove an installed plugin.
Remove(RemovePluginArgs),
}
#[derive(Debug, Parser)]
#[command(bin_name = "codex plugin add")]
pub struct AddPluginArgs {
/// Plugin to install. Accepts <plugin> with --marketplace or <plugin>@<marketplace>.
plugin: String,
/// Marketplace name containing the plugin.
#[arg(long = "marketplace", short = 'm')]
marketplace_name: Option<String>,
}
#[derive(Debug, Parser)]
#[command(bin_name = "codex plugin list")]
pub struct ListPluginsArgs {
/// Only list plugins in this marketplace.
#[arg(long = "marketplace", short = 'm')]
marketplace_name: Option<String>,
}
#[derive(Debug, Parser)]
#[command(bin_name = "codex plugin remove")]
pub struct RemovePluginArgs {
/// Plugin to remove. Accepts <plugin> with --marketplace or <plugin>@<marketplace>.
plugin: String,
/// Marketplace name containing the plugin.
#[arg(long = "marketplace", short = 'm')]
marketplace_name: Option<String>,
}
pub async fn run_plugin_add(
overrides: Vec<(String, toml::Value)>,
args: AddPluginArgs,
) -> Result<()> {
let PluginCommandContext {
plugins_input,
manager,
} = load_plugin_command_context(overrides).await?;
let PluginSelection {
plugin_name,
marketplace_name,
..
} = parse_plugin_selection(args.plugin, args.marketplace_name)?;
let marketplace =
find_marketplace_for_plugin(&manager, &plugins_input, &marketplace_name, &plugin_name)?;
let outcome = manager
.install_plugin(PluginInstallRequest {
plugin_name,
marketplace_path: marketplace.path,
})
.await?;
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(())
}
pub async fn run_plugin_list(
overrides: Vec<(String, toml::Value)>,
args: ListPluginsArgs,
) -> Result<()> {
let PluginCommandContext {
plugins_input,
manager,
..
} = load_plugin_command_context(overrides).await?;
let current_dir = AbsolutePathBuf::try_from(std::env::current_dir()?)
.context("failed to resolve current directory")?;
let outcome = manager
.list_marketplaces_for_config(&plugins_input, &[current_dir])
.context("failed to list marketplace plugins")?;
let marketplaces = outcome
.marketplaces
.into_iter()
.filter(|marketplace| {
args.marketplace_name
.as_ref()
.is_none_or(|name| marketplace.name == *name)
})
.collect::<Vec<_>>();
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 marketplace in marketplaces {
println!("Marketplace `{}`", marketplace.name);
println!("Path: {}", marketplace.path.as_path().display());
for plugin in &marketplace.plugins {
let state = if plugin.installed && plugin.enabled {
"installed, enabled"
} else if plugin.installed {
"installed, disabled"
} else {
"not installed"
};
println!(" {} ({state})", plugin.id);
}
}
}
for error in outcome.errors {
eprintln!(
"Failed to load marketplace {}: {}",
error.path.as_path().display(),
error.message
);
}
Ok(())
}
pub async fn run_plugin_remove(
overrides: Vec<(String, toml::Value)>,
args: RemovePluginArgs,
) -> Result<()> {
let PluginCommandContext { manager, .. } = load_plugin_command_context(overrides).await?;
let selection = parse_plugin_selection(args.plugin, args.marketplace_name)?;
manager.uninstall_plugin(selection.plugin_key).await?;
println!(
"Removed plugin `{}` from marketplace `{}`.",
selection.plugin_name, selection.marketplace_name
);
Ok(())
}
struct PluginCommandContext {
plugins_input: PluginsConfigInput,
manager: PluginsManager,
}
async fn load_plugin_command_context(
overrides: Vec<(String, toml::Value)>,
) -> Result<PluginCommandContext> {
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());
Ok(PluginCommandContext {
plugins_input,
manager,
})
}
struct PluginSelection {
plugin_name: String,
marketplace_name: String,
plugin_key: String,
}
fn parse_plugin_selection(
plugin: String,
marketplace_name: Option<String>,
) -> Result<PluginSelection> {
match (PluginId::parse(&plugin), marketplace_name) {
(Ok(plugin_id), None) => {
let plugin_key = plugin_id.as_key();
Ok(PluginSelection {
plugin_name: plugin_id.plugin_name,
marketplace_name: plugin_id.marketplace_name,
plugin_key,
})
}
(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
);
}
let plugin_key = plugin_id.as_key();
Ok(PluginSelection {
plugin_name: plugin_id.plugin_name,
marketplace_name: plugin_id.marketplace_name,
plugin_key,
})
}
(Err(_), Some(marketplace_name)) => {
let plugin_id = PluginId::new(plugin, marketplace_name)?;
let plugin_key = plugin_id.as_key();
Ok(PluginSelection {
plugin_name: plugin_id.plugin_name,
marketplace_name: plugin_id.marketplace_name,
plugin_key,
})
}
(Err(_), None) => {
bail!("plugin requires --marketplace unless passed as <plugin>@<marketplace>")
}
}
}
fn find_marketplace_for_plugin(
manager: &PluginsManager,
plugins_input: &PluginsConfigInput,
marketplace_name: &str,
plugin_name: &str,
) -> Result<ConfiguredMarketplace> {
let current_dir = AbsolutePathBuf::try_from(std::env::current_dir()?)
.context("failed to resolve current directory")?;
let matches = manager
.list_marketplaces_for_config(plugins_input, &[current_dir])
.context("failed to list marketplace plugins")?
.marketplaces
.into_iter()
.filter(|marketplace| marketplace.name == marketplace_name)
.filter(|marketplace| {
marketplace
.plugins
.iter()
.any(|plugin| plugin.name == plugin_name)
})
.collect::<Vec<_>>();
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"
),
}
}

View File

@@ -0,0 +1,178 @@
use anyhow::Result;
use codex_config::CONFIG_TOML_FILE;
use codex_config::MarketplaceConfigUpdate;
use codex_config::record_user_marketplace;
use predicates::str::contains;
use std::path::Path;
use tempfile::TempDir;
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}
fn configured_local_marketplace(source: &str) -> MarketplaceConfigUpdate<'_> {
MarketplaceConfigUpdate {
last_updated: "2026-05-06T00:00:00Z",
last_revision: None,
source_type: "local",
source,
ref_name: None,
sparse_paths: &[],
}
}
fn write_plugins_enabled_config(codex_home: &Path) -> Result<()> {
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
)?;
Ok(())
}
fn write_marketplace_source(source: &Path) -> Result<()> {
std::fs::create_dir_all(source.join(".agents/plugins"))?;
std::fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?;
std::fs::write(
source.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
}
}
]
}"#,
)?;
std::fs::write(
source.join("plugins/sample/.codex-plugin/plugin.json"),
r#"{"name":"sample","description":"Sample plugin"}"#,
)?;
Ok(())
}
#[tokio::test]
async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
write_marketplace_source(source.path())?;
let source_path = source.path().to_string_lossy().into_owned();
record_user_marketplace(
codex_home.path(),
"debug",
&configured_local_marketplace(&source_path),
)?;
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert()
.success()
.stdout(contains("debug"))
.stdout(contains(source.path().display().to_string()));
Ok(())
}
#[tokio::test]
async fn plugin_list_shows_plugins_grouped_by_marketplace() -> Result<()> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
write_marketplace_source(source.path())?;
let source_path = source.path().to_string_lossy().into_owned();
record_user_marketplace(
codex_home.path(),
"debug",
&configured_local_marketplace(&source_path),
)?;
codex_command(codex_home.path())?
.args(["plugin", "list"])
.assert()
.success()
.stdout(contains("Marketplace `debug`"))
.stdout(contains("sample@debug (not installed)"));
Ok(())
}
#[tokio::test]
async fn plugin_add_and_remove_updates_installed_plugin_config() -> Result<()> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
write_marketplace_source(source.path())?;
let source_path = source.path().to_string_lossy().into_owned();
record_user_marketplace(
codex_home.path(),
"debug",
&configured_local_marketplace(&source_path),
)?;
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert()
.success()
.stdout(contains("Added plugin `sample` from marketplace `debug`."));
let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?;
assert!(config.contains("[plugins.\"sample@debug\"]"));
codex_command(codex_home.path())?
.args(["plugin", "remove", "sample", "--marketplace", "debug"])
.assert()
.success()
.stdout(contains(
"Removed plugin `sample` from marketplace `debug`.",
));
let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?;
assert!(!config.contains("[plugins.\"sample@debug\"]"));
Ok(())
}
#[tokio::test]
async fn plugin_remove_works_after_marketplace_is_removed() -> Result<()> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
write_marketplace_source(source.path())?;
let source_path = source.path().to_string_lossy().into_owned();
record_user_marketplace(
codex_home.path(),
"debug",
&configured_local_marketplace(&source_path),
)?;
codex_command(codex_home.path())?
.args(["plugin", "add", "sample", "--marketplace", "debug"])
.assert()
.success();
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "remove", "debug"])
.assert()
.success();
codex_command(codex_home.path())?
.args(["plugin", "remove", "sample@debug"])
.assert()
.success()
.stdout(contains(
"Removed plugin `sample` from marketplace `debug`.",
));
let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?;
assert!(!config.contains("[plugins.\"sample@debug\"]"));
Ok(())
}