[codex] Add plugin list JSON output (#25330)

## Summary
- add `--json` output to `codex plugin list` with `installed` and
`available` arrays
- add `--available` for JSON output only; using it without `--json` is
rejected
- keep the existing non-JSON table output unchanged
- add CLI coverage for JSON installed/available output and the
`--available`/`--json` requirement

## Validation
- `just test -p codex-cli plugin_list`
- `just fix -p codex-cli`
- `git diff --check`

Note: `just fmt` ran Rust formatting first, then failed in the Python
ruff step because `openai-codex-cli-bin==0.132.0` has no wheel for this
Linux platform.
This commit is contained in:
xl-openai
2026-06-01 21:27:06 -07:00
committed by GitHub
Unverified
parent c8e5db16c9
commit cb63ee7f5d
2 changed files with 250 additions and 1 deletions
+151 -1
View File
@@ -12,10 +12,14 @@ 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_plugin::PluginId;
use codex_plugin::validate_plugin_segment;
use codex_utils_cli::CliConfigOverrides;
use serde::Serialize;
use std::path::Path;
use std::path::PathBuf;
@@ -73,12 +77,20 @@ pub struct AddPluginArgs {
#[derive(Debug, Parser)]
#[command(
bin_name = "codex plugin list",
after_help = "Examples:\n codex plugin list\n codex plugin list --marketplace debug"
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<String>,
/// 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)]
@@ -166,6 +178,12 @@ pub async fn run_plugin_list(
})
.collect::<Vec<_>>();
if args.json {
let output = JsonPluginListOutput::from_marketplaces(marketplaces, args.available);
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}`.");
@@ -240,6 +258,138 @@ pub async fn run_plugin_list(
Ok(())
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonPluginListOutput {
installed: Vec<JsonPluginListEntry>,
available: Vec<JsonPluginListEntry>,
}
impl JsonPluginListOutput {
fn from_marketplaces(
marketplaces: Vec<codex_core_plugins::ConfiguredMarketplace>,
include_available: bool,
) -> Self {
let mut installed = Vec::new();
let mut available = Vec::new();
for marketplace in marketplaces {
for plugin in marketplace.plugins {
let entry = JsonPluginListEntry::from_configured_plugin(&marketplace.name, 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<String>,
installed: bool,
enabled: bool,
source: JsonPluginSource,
install_policy: &'static str,
auth_policy: &'static str,
}
impl JsonPluginListEntry {
fn from_configured_plugin(
marketplace_name: &str,
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),
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sha: Option<String>,
},
GitSubdir {
url: String,
path: String,
#[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
ref_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sha: Option<String>,
},
}
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 },
}
}
}
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,
+99
View File
@@ -4,6 +4,8 @@ use codex_config::MarketplaceConfigUpdate;
use codex_config::record_user_marketplace;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::Path;
use tempfile::TempDir;
@@ -489,6 +491,103 @@ async fn plugin_list_prints_plugins_in_a_table() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<()> {
let (codex_home, source) = setup_local_marketplace()?;
let plugin_path = source.path().join("plugins").join("sample");
let assert = codex_command(codex_home.path())?
.args(["plugin", "list", "--available", "--json"])
.assert()
.success();
let stdout = assert.get_output().stdout.as_slice();
let actual: serde_json::Value = serde_json::from_slice(stdout)?;
assert_eq!(
actual,
json!({
"installed": [],
"available": [
{
"pluginId": "sample@debug",
"name": "sample",
"marketplaceName": "debug",
"version": "1.2.3",
"installed": false,
"enabled": false,
"source": {
"source": "local",
"path": plugin_path.display().to_string(),
},
"installPolicy": "AVAILABLE",
"authPolicy": "ON_INSTALL",
},
],
})
);
Ok(())
}
#[tokio::test]
async fn plugin_list_json_prints_installed_plugins() -> Result<()> {
let (codex_home, source) = setup_local_marketplace()?;
let plugin_path = source.path().join("plugins").join("sample");
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert()
.success();
let assert = codex_command(codex_home.path())?
.args(["plugin", "list", "--json"])
.assert()
.success();
let stdout = assert.get_output().stdout.as_slice();
let actual: serde_json::Value = serde_json::from_slice(stdout)?;
assert_eq!(
actual,
json!({
"installed": [
{
"pluginId": "sample@debug",
"name": "sample",
"marketplaceName": "debug",
"version": "1.2.3",
"installed": true,
"enabled": true,
"source": {
"source": "local",
"path": plugin_path.display().to_string(),
},
"installPolicy": "AVAILABLE",
"authPolicy": "ON_INSTALL",
},
],
"available": [],
})
);
Ok(())
}
#[tokio::test]
async fn plugin_list_available_requires_json() -> Result<()> {
let (codex_home, _source) = setup_local_marketplace()?;
codex_command(codex_home.path())?
.args(["plugin", "list", "--available"])
.assert()
.failure()
.stderr(contains(
"the following required arguments were not provided",
))
.stderr(contains("--json"));
Ok(())
}
#[tokio::test]
async fn plugin_list_shows_installed_version_when_plugin_is_installed() -> Result<()> {
let (codex_home, _source) = setup_local_marketplace()?;