mirror of
https://github.com/openai/codex.git
synced 2026-05-26 14:04:48 +00:00
525 lines
17 KiB
Rust
525 lines
17 KiB
Rust
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::OPENAI_BUNDLED_MARKETPLACE_NAME;
|
|
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::find_marketplace_manifest_path;
|
|
use codex_plugin::PluginId;
|
|
use codex_plugin::validate_plugin_segment;
|
|
use codex_utils_cli::CliConfigOverrides;
|
|
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<String>,
|
|
}
|
|
|
|
#[derive(Debug, Parser)]
|
|
#[command(
|
|
bin_name = "codex plugin list",
|
|
after_help = "Examples:\n codex plugin list\n codex plugin list --marketplace debug"
|
|
)]
|
|
pub struct ListPluginsArgs {
|
|
/// Only list plugins from this configured marketplace name.
|
|
#[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")]
|
|
marketplace_name: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
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 PluginSelection {
|
|
plugin_name,
|
|
marketplace_name,
|
|
..
|
|
} = parse_plugin_selection(args.plugin, args.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?;
|
|
|
|
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 {
|
|
codex_home,
|
|
plugins_input,
|
|
manager,
|
|
..
|
|
} = load_plugin_command_context(overrides).await?;
|
|
let outcome = manager
|
|
.list_marketplaces_for_config(&plugins_input, &[])
|
|
.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::<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 (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!(
|
|
"{:<plugin_width$} {:<status_width$} {:<installed_version_width$} {:<path_width$}",
|
|
"PLUGIN", "STATUS", "VERSION", "PATH"
|
|
);
|
|
for (plugin, status, installed_version, path) in rows {
|
|
println!(
|
|
"{plugin:<plugin_width$} {status:<status_width$} {installed_version:<installed_version_width$} {path:<path_width$}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
codex_home: PathBuf,
|
|
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 {
|
|
codex_home: codex_home.to_path_buf(),
|
|
plugins_input,
|
|
manager,
|
|
})
|
|
}
|
|
|
|
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<String>,
|
|
) -> Result<PluginSelection> {
|
|
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 <plugin>@<marketplace>")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn find_marketplace_for_plugin(
|
|
manager: &PluginsManager,
|
|
codex_home: &std::path::Path,
|
|
plugins_input: &PluginsConfigInput,
|
|
marketplace_name: &str,
|
|
plugin_name: &str,
|
|
) -> Result<ConfiguredMarketplace> {
|
|
let outcome = manager
|
|
.list_marketplaces_for_config(plugins_input, &[])
|
|
.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::<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"
|
|
),
|
|
}
|
|
}
|
|
|
|
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::<Vec<_>>()
|
|
.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<ConfiguredMarketplaceSnapshotIssue> {
|
|
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("<invalid config>"),
|
|
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("<invalid config>"),
|
|
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("<invalid source>"),
|
|
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::<Vec<_>>();
|
|
path_components.as_slice().ends_with(
|
|
&suffix
|
|
.iter()
|
|
.map(std::string::ToString::to_string)
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
}
|