mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Propagate plugin app categories (#27420)
## What - Parse optional `.app.json` `category` overrides for plugin apps. - Add nullable `category` to `AppSummary` and `AppTemplateSummary` in the app-server protocol. - Fall back from `branding.category` to the first non-empty `app_metadata.categories` value when building app/template summaries. - Regenerate schema/type fixtures and update plugin read/install tests. ## Why The plugin details UI needs a normalized per-app category. Some apps only provide their default category in metadata, while others need a local `.app.json` override.
This commit is contained in:
@@ -61,6 +61,12 @@ pub struct PluginHookLoadOutcome {
|
||||
pub hook_load_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginAppMetadata {
|
||||
pub id: AppConnectorId,
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
enum PluginLoadScope<'a> {
|
||||
AllCapabilities {
|
||||
restriction_product: Option<Product>,
|
||||
@@ -123,6 +129,7 @@ struct PluginAppFile {
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct PluginAppConfig {
|
||||
id: String,
|
||||
category: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn load_plugins_from_layer_stack(
|
||||
@@ -848,6 +855,14 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec<AbsolutePathBuf> {
|
||||
}
|
||||
|
||||
pub async fn load_plugin_apps(plugin_root: &Path) -> Vec<AppConnectorId> {
|
||||
load_plugin_app_metadata(plugin_root)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|app| app.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn load_plugin_app_metadata(plugin_root: &Path) -> Vec<PluginAppMetadata> {
|
||||
if let Some(manifest) = load_plugin_manifest(plugin_root) {
|
||||
return load_apps_from_paths(
|
||||
plugin_root,
|
||||
@@ -858,6 +873,16 @@ pub async fn load_plugin_apps(plugin_root: &Path) -> Vec<AppConnectorId> {
|
||||
load_apps_from_paths(plugin_root, default_app_config_paths(plugin_root)).await
|
||||
}
|
||||
|
||||
pub fn plugin_app_metadata_from_value(value: &JsonValue) -> Vec<PluginAppMetadata> {
|
||||
let Ok(parsed) = serde_json::from_value::<PluginAppFile>(value.clone()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut apps = plugin_app_metadata_from_file(parsed, /*plugin_root*/ None);
|
||||
let mut seen_connector_ids = HashSet::new();
|
||||
apps.retain(|app| seen_connector_ids.insert(app.id.0.clone()));
|
||||
apps
|
||||
}
|
||||
|
||||
fn plugin_app_config_paths(
|
||||
plugin_root: &Path,
|
||||
manifest_paths: &PluginManifestPaths,
|
||||
@@ -994,8 +1019,8 @@ fn append_plugin_hook_file(
|
||||
async fn load_apps_from_paths(
|
||||
plugin_root: &Path,
|
||||
app_config_paths: Vec<AbsolutePathBuf>,
|
||||
) -> Vec<AppConnectorId> {
|
||||
let mut connector_ids = Vec::new();
|
||||
) -> Vec<PluginAppMetadata> {
|
||||
let mut apps = Vec::new();
|
||||
for app_config_path in app_config_paths {
|
||||
let Ok(contents) = tokio::fs::read_to_string(app_config_path.as_path()).await else {
|
||||
continue;
|
||||
@@ -1011,21 +1036,40 @@ async fn load_apps_from_paths(
|
||||
}
|
||||
};
|
||||
|
||||
connector_ids.extend(parsed.apps.into_values().filter_map(|app| {
|
||||
if app.id.trim().is_empty() {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
"plugin app config is missing an app id"
|
||||
);
|
||||
None
|
||||
} else {
|
||||
Some(AppConnectorId(app.id))
|
||||
}
|
||||
}));
|
||||
apps.extend(plugin_app_metadata_from_file(parsed, Some(plugin_root)));
|
||||
}
|
||||
let mut seen_connector_ids = HashSet::new();
|
||||
connector_ids.retain(|connector_id| seen_connector_ids.insert(connector_id.0.clone()));
|
||||
connector_ids
|
||||
apps.retain(|app| seen_connector_ids.insert(app.id.0.clone()));
|
||||
apps
|
||||
}
|
||||
|
||||
fn plugin_app_metadata_from_file(
|
||||
parsed: PluginAppFile,
|
||||
plugin_root: Option<&Path>,
|
||||
) -> Vec<PluginAppMetadata> {
|
||||
parsed
|
||||
.apps
|
||||
.into_values()
|
||||
.filter_map(|app| {
|
||||
if app.id.trim().is_empty() {
|
||||
if let Some(plugin_root) = plugin_root {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
"plugin app config is missing an app id"
|
||||
);
|
||||
}
|
||||
None
|
||||
} else {
|
||||
Some(PluginAppMetadata {
|
||||
id: AppConnectorId(app.id),
|
||||
category: app
|
||||
.category
|
||||
.map(|category| category.trim().to_string())
|
||||
.filter(|category| !category.is_empty()),
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn plugin_telemetry_metadata_from_root(
|
||||
@@ -1063,7 +1107,10 @@ pub async fn plugin_telemetry_metadata_from_root(
|
||||
plugin_root.as_path(),
|
||||
plugin_app_config_paths(plugin_root.as_path(), manifest_paths),
|
||||
)
|
||||
.await,
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|app| app.id)
|
||||
.collect(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::loader::PluginHookLoadOutcome;
|
||||
use crate::loader::configured_curated_plugin_ids_from_codex_home;
|
||||
use crate::loader::curated_plugin_cache_version;
|
||||
use crate::loader::installed_plugin_telemetry_metadata;
|
||||
use crate::loader::load_plugin_apps;
|
||||
use crate::loader::load_plugin_app_metadata;
|
||||
use crate::loader::load_plugin_hooks;
|
||||
use crate::loader::load_plugin_hooks_from_layer_stack;
|
||||
use crate::loader::load_plugin_mcp_servers;
|
||||
@@ -254,6 +254,7 @@ pub struct PluginDetail {
|
||||
pub disabled_skill_paths: HashSet<AbsolutePathBuf>,
|
||||
pub hooks: Vec<PluginHookSummary>,
|
||||
pub apps: Vec<AppConnectorId>,
|
||||
pub app_category_by_id: HashMap<String, String>,
|
||||
pub mcp_server_names: Vec<String>,
|
||||
pub details_unavailable_reason: Option<PluginDetailsUnavailableReason>,
|
||||
}
|
||||
@@ -1224,6 +1225,7 @@ impl PluginsManager {
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
hooks: Vec::new(),
|
||||
apps: Vec::new(),
|
||||
app_category_by_id: HashMap::new(),
|
||||
mcp_server_names: Vec::new(),
|
||||
details_unavailable_reason: Some(
|
||||
PluginDetailsUnavailableReason::InstallRequiredForRemoteSource,
|
||||
@@ -1290,7 +1292,12 @@ impl PluginsManager {
|
||||
event_name: hook.event_name,
|
||||
})
|
||||
.collect();
|
||||
let apps = load_plugin_apps(source_path.as_path()).await;
|
||||
let app_metadata = load_plugin_app_metadata(source_path.as_path()).await;
|
||||
let apps = app_metadata.iter().map(|app| app.id.clone()).collect();
|
||||
let app_category_by_id = app_metadata
|
||||
.into_iter()
|
||||
.filter_map(|app| app.category.map(|category| (app.id.0, category)))
|
||||
.collect();
|
||||
let mut mcp_server_names = load_plugin_mcp_servers(source_path.as_path())
|
||||
.await
|
||||
.into_keys()
|
||||
@@ -1313,6 +1320,7 @@ impl PluginsManager {
|
||||
disabled_skill_paths: resolved_skills.disabled_skill_paths,
|
||||
hooks,
|
||||
apps,
|
||||
app_category_by_id,
|
||||
mcp_server_names,
|
||||
details_unavailable_reason: None,
|
||||
})
|
||||
|
||||
@@ -178,6 +178,7 @@ pub struct RemoteAppTemplate {
|
||||
pub template_id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub canonical_connector_id: Option<String>,
|
||||
pub logo_url: Option<String>,
|
||||
pub logo_url_dark: Option<String>,
|
||||
@@ -458,6 +459,8 @@ struct RemoteAppTemplateResponse {
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
category: Option<String>,
|
||||
#[serde(default)]
|
||||
canonical_connector_id: Option<String>,
|
||||
#[serde(default)]
|
||||
logo_url: Option<String>,
|
||||
@@ -1059,6 +1062,7 @@ async fn build_remote_plugin_detail(
|
||||
template_id: template.template_id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category,
|
||||
canonical_connector_id: template.canonical_connector_id,
|
||||
logo_url: template.logo_url,
|
||||
logo_url_dark: template.logo_url_dark,
|
||||
|
||||
Reference in New Issue
Block a user