From 04ae78c7bf35008abe1035f71943a5d3def84599 Mon Sep 17 00:00:00 2001 From: Noah MacCallum Date: Mon, 11 May 2026 16:45:32 -0700 Subject: [PATCH] Expose plugin provenance for listed skills --- .../codex_app_server_protocol.schemas.json | 80 +++++++++++ .../codex_app_server_protocol.v2.schemas.json | 80 +++++++++++ .../schema/json/v2/SkillsListResponse.json | 80 +++++++++++ .../schema/typescript/v2/SkillMetadata.ts | 4 +- .../typescript/v2/SkillPluginMetadata.ts | 6 + .../schema/typescript/v2/SkillProvenance.ts | 5 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/v2/plugin.rs | 45 ++++++ codex-rs/app-server/README.md | 4 + .../request_processors/catalog_processor.rs | 102 +++++++++++++- .../app-server/tests/suite/v2/skills_list.rs | 133 +++++++++++++++++- codex-rs/core-plugins/src/loader.rs | 11 ++ codex-rs/core-plugins/src/manager_tests.rs | 3 + codex-rs/plugin/src/lib.rs | 1 + codex-rs/plugin/src/load_outcome.rs | 9 ++ 15 files changed, 554 insertions(+), 11 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SkillPluginMetadata.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SkillProvenance.ts diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 4e061f8591f1..1c971cffc352 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14681,6 +14681,19 @@ "path": { "$ref": "#/definitions/v2/AbsolutePathBuf" }, + "plugin": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SkillPluginMetadata" + }, + { + "type": "null" + } + ] + }, + "provenance": { + "$ref": "#/definitions/v2/SkillProvenance" + }, "scope": { "$ref": "#/definitions/v2/SkillScope" }, @@ -14697,10 +14710,77 @@ "enabled", "name", "path", + "provenance", "scope" ], "type": "object" }, + "SkillPluginMetadata": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "composerIcon": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "marketplaceName": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "marketplaceName", + "name" + ], + "type": "object" + }, + "SkillProvenance": { + "enum": [ + "personal", + "project", + "system", + "admin", + "openai_marketplace", + "workspace_marketplace", + "custom_marketplace", + "local_plugin", + "debug_plugin", + "ad_hoc_plugin" + ], + "type": "string" + }, "SkillScope": { "enum": [ "user", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index c7130126f9bc..2515932cfdd6 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12505,6 +12505,19 @@ "path": { "$ref": "#/definitions/AbsolutePathBuf" }, + "plugin": { + "anyOf": [ + { + "$ref": "#/definitions/SkillPluginMetadata" + }, + { + "type": "null" + } + ] + }, + "provenance": { + "$ref": "#/definitions/SkillProvenance" + }, "scope": { "$ref": "#/definitions/SkillScope" }, @@ -12521,10 +12534,77 @@ "enabled", "name", "path", + "provenance", "scope" ], "type": "object" }, + "SkillPluginMetadata": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "composerIcon": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "marketplaceName": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "marketplaceName", + "name" + ], + "type": "object" + }, + "SkillProvenance": { + "enum": [ + "personal", + "project", + "system", + "admin", + "openai_marketplace", + "workspace_marketplace", + "custom_marketplace", + "local_plugin", + "debug_plugin", + "ad_hoc_plugin" + ], + "type": "string" + }, "SkillScope": { "enum": [ "user", diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsListResponse.json index 6c72bfbb6895..05f2454e9441 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/SkillsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsListResponse.json @@ -117,6 +117,19 @@ "path": { "$ref": "#/definitions/AbsolutePathBuf" }, + "plugin": { + "anyOf": [ + { + "$ref": "#/definitions/SkillPluginMetadata" + }, + { + "type": "null" + } + ] + }, + "provenance": { + "$ref": "#/definitions/SkillProvenance" + }, "scope": { "$ref": "#/definitions/SkillScope" }, @@ -133,10 +146,77 @@ "enabled", "name", "path", + "provenance", "scope" ], "type": "object" }, + "SkillPluginMetadata": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "composerIcon": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "marketplaceName": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "marketplaceName", + "name" + ], + "type": "object" + }, + "SkillProvenance": { + "enum": [ + "personal", + "project", + "system", + "admin", + "openai_marketplace", + "workspace_marketplace", + "custom_marketplace", + "local_plugin", + "debug_plugin", + "ad_hoc_plugin" + ], + "type": "string" + }, "SkillScope": { "enum": [ "user", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillMetadata.ts index e43484d1f46a..fc0148dd185b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillMetadata.ts @@ -4,10 +4,12 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { SkillDependencies } from "./SkillDependencies"; import type { SkillInterface } from "./SkillInterface"; +import type { SkillPluginMetadata } from "./SkillPluginMetadata"; +import type { SkillProvenance } from "./SkillProvenance"; import type { SkillScope } from "./SkillScope"; export type SkillMetadata = { name: string, description: string, /** * Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description. */ -shortDescription?: string, interface?: SkillInterface, dependencies?: SkillDependencies, path: AbsolutePathBuf, scope: SkillScope, enabled: boolean, }; +shortDescription?: string, interface?: SkillInterface, dependencies?: SkillDependencies, path: AbsolutePathBuf, scope: SkillScope, provenance: SkillProvenance, plugin: SkillPluginMetadata | null, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillPluginMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillPluginMetadata.ts new file mode 100644 index 000000000000..05ba3e4c2d50 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillPluginMetadata.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type SkillPluginMetadata = { id: string, name: string, marketplaceName: string, displayName: string | null, brandColor: string | null, composerIcon: AbsolutePathBuf | null, logo: AbsolutePathBuf | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillProvenance.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillProvenance.ts new file mode 100644 index 000000000000..33d26859a4be --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillProvenance.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SkillProvenance = "personal" | "project" | "system" | "admin" | "openai_marketplace" | "workspace_marketplace" | "custom_marketplace" | "local_plugin" | "debug_plugin" | "ad_hoc_plugin"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index a6b961366e0f..3d13ef1b4aea 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -334,6 +334,8 @@ export type { SkillDependencies } from "./SkillDependencies"; export type { SkillErrorInfo } from "./SkillErrorInfo"; export type { SkillInterface } from "./SkillInterface"; export type { SkillMetadata } from "./SkillMetadata"; +export type { SkillPluginMetadata } from "./SkillPluginMetadata"; +export type { SkillProvenance } from "./SkillProvenance"; export type { SkillScope } from "./SkillScope"; export type { SkillSummary } from "./SkillSummary"; export type { SkillToolDependency } from "./SkillToolDependency"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index ed03cc6ff3df..0dd4a2a06a76 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -350,6 +350,23 @@ pub enum SkillScope { Admin, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum SkillProvenance { + Personal, + Project, + System, + Admin, + OpenaiMarketplace, + WorkspaceMarketplace, + CustomMarketplace, + LocalPlugin, + DebugPlugin, + AdHocPlugin, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -368,9 +385,24 @@ pub struct SkillMetadata { pub dependencies: Option, pub path: AbsolutePathBuf, pub scope: SkillScope, + pub provenance: SkillProvenance, + pub plugin: Option, pub enabled: bool, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillPluginMetadata { + pub id: String, + pub name: String, + pub marketplace_name: String, + pub display_name: Option, + pub brand_color: Option, + pub composer_icon: Option, + pub logo: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -713,11 +745,24 @@ impl From for SkillMetadata { dependencies: value.dependencies.map(SkillDependencies::from), path: value.path, scope: value.scope.into(), + provenance: value.scope.into(), + plugin: None, enabled: true, } } } +impl From for SkillProvenance { + fn from(value: CoreSkillScope) -> Self { + match value { + CoreSkillScope::User => Self::Personal, + CoreSkillScope::Repo => Self::Project, + CoreSkillScope::System => Self::System, + CoreSkillScope::Admin => Self::Admin, + } + } +} + impl From for SkillInterface { fn from(value: CoreSkillInterface) -> Self { Self { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c16762725791..061dd9dfa9ad 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1470,6 +1470,7 @@ $skill-creator Add a new skill for triaging flaky CI and include step-by-step us Use `skills/list` to fetch the available skills (optionally scoped by `cwds`, with `forceReload`). `skills/list` might reuse a cached skills result per `cwd`; setting `forceReload` to `true` refreshes the result from disk. +Each skill includes a user-facing `provenance`; skills loaded from installed/local plugins also include compact `plugin` metadata for display name, marketplace, brand color, and local icon assets. The server also emits `skills/changed` notifications when watched local skill files change. Treat this as an invalidation signal and re-run `skills/list` with your current params when needed. ```json @@ -1485,6 +1486,9 @@ The server also emits `skills/changed` notifications when watched local skill fi "name": "skill-creator", "description": "Create or update a Codex skill", "enabled": true, + "scope": "user", + "provenance": "personal", + "plugin": null, "interface": { "displayName": "Skill Creator", "shortDescription": "Create or update a Codex skill", diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index 89082492c139..199a8de80db0 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -12,14 +12,36 @@ pub(crate) struct CatalogRequestProcessor { const SKILLS_LIST_CWD_CONCURRENCY: usize = 5; +#[derive(Clone)] +struct SkillPluginInfo { + id: String, + name: String, + marketplace_name: String, + display_name: Option, + provenance: codex_app_server_protocol::SkillProvenance, + brand_color: Option, + composer_icon: Option, + logo: Option, +} + fn skills_to_info( skills: &[codex_core::skills::SkillMetadata], disabled_paths: &HashSet, + plugins_by_id: &HashMap, ) -> Vec { skills .iter() .map(|skill| { let enabled = !disabled_paths.contains(&skill.path_to_skills_md); + let plugin = skill + .plugin_id + .as_ref() + .and_then(|plugin_id| plugins_by_id.get(plugin_id)) + .cloned(); + let provenance = plugin + .as_ref() + .map(|plugin| plugin.provenance) + .unwrap_or_else(|| skill.scope.into()); codex_app_server_protocol::SkillMetadata { name: skill.name.clone(), description: skill.description.clone(), @@ -52,12 +74,73 @@ fn skills_to_info( }), path: skill.path_to_skills_md.clone(), scope: skill.scope.into(), + provenance, + plugin: plugin.map(|plugin| codex_app_server_protocol::SkillPluginMetadata { + id: plugin.id, + name: plugin.name, + marketplace_name: plugin.marketplace_name, + display_name: plugin.display_name, + brand_color: plugin.brand_color, + composer_icon: plugin.composer_icon, + logo: plugin.logo, + }), enabled, } }) .collect() } +fn plugin_infos_by_id( + plugins: &[codex_plugin::LoadedPlugin], +) -> HashMap { + plugins + .iter() + .filter(|plugin| plugin.enabled && plugin.error.is_none()) + .filter_map(|plugin| { + let plugin_id = codex_plugin::PluginId::parse(&plugin.config_name).ok()?; + let provenance = plugin_provenance(&plugin_id.marketplace_name); + Some(( + plugin.config_name.clone(), + SkillPluginInfo { + id: plugin.config_name.clone(), + name: plugin_id.plugin_name, + marketplace_name: plugin_id.marketplace_name, + display_name: plugin.manifest_name.clone(), + provenance, + brand_color: plugin + .manifest_interface + .as_ref() + .and_then(|interface| interface.brand_color.clone()), + composer_icon: plugin + .manifest_interface + .as_ref() + .and_then(|interface| interface.composer_icon.clone()), + logo: plugin + .manifest_interface + .as_ref() + .and_then(|interface| interface.logo.clone()), + }, + )) + }) + .collect() +} + +fn plugin_provenance(marketplace_name: &str) -> codex_app_server_protocol::SkillProvenance { + match marketplace_name { + codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME + | codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME => { + codex_app_server_protocol::SkillProvenance::OpenaiMarketplace + } + codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME + | codex_core_plugins::remote::REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME => { + codex_app_server_protocol::SkillProvenance::WorkspaceMarketplace + } + "debug" => codex_app_server_protocol::SkillProvenance::DebugPlugin, + "local" | "personal" => codex_app_server_protocol::SkillProvenance::LocalPlugin, + _ => codex_app_server_protocol::SkillProvenance::CustomMarketplace, + } +} + fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec { hooks .iter() @@ -425,16 +508,22 @@ impl CatalogRequestProcessor { ); } }; - let effective_skill_roots = if workspace_codex_plugins_enabled { + let (effective_skill_roots, plugins_by_id) = if workspace_codex_plugins_enabled + { let plugins_input = config.plugins_config_input(); - plugins_manager - .effective_skill_roots_for_layer_stack( + let plugin_outcome = plugins_manager + .plugins_for_layer_stack( &config_layer_stack, &plugins_input, + plugins_input.plugin_hooks_enabled, ) - .await + .await; + ( + plugin_outcome.effective_plugin_skill_roots(), + plugin_infos_by_id(plugin_outcome.plugins()), + ) } else { - Vec::new() + (Vec::new(), HashMap::new()) }; let skills_input = codex_core::skills::SkillsLoadInput::new( cwd_abs.clone(), @@ -446,7 +535,8 @@ impl CatalogRequestProcessor { .skills_for_cwd(&skills_input, force_reload, fs) .await; let errors = errors_to_info(&outcome.errors); - let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths); + let skills = + skills_to_info(&outcome.skills, &outcome.disabled_paths, &plugins_by_id); ( index, codex_app_server_protocol::SkillsListEntry { diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 416b2515adf0..e440949163fd 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -12,12 +12,14 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SkillProvenance; use codex_app_server_protocol::SkillsChangedNotification; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadStartParams; use codex_config::types::AuthCredentialsStoreMode; use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; @@ -40,9 +42,10 @@ fn write_skill(root: &TempDir, name: &str) -> Result<()> { Ok(()) } -fn write_plugins_enabled_config_with_base_url( +fn write_plugins_enabled_config_with_enabled_plugin( codex_home: &std::path::Path, base_url: &str, + plugin_id: &str, ) -> std::io::Result<()> { std::fs::write( codex_home.join("config.toml"), @@ -51,6 +54,9 @@ fn write_plugins_enabled_config_with_base_url( [features] plugins = true + +[plugins."{plugin_id}"] +enabled = true "#, ), ) @@ -100,9 +106,20 @@ fn write_plugin_with_skill( let plugin_root = repo_root.join(plugin_name); std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::create_dir_all(plugin_root.join("assets"))?; + std::fs::write(plugin_root.join("assets/icon.png"), &[] as &[u8])?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), - format!(r#"{{"name":"{plugin_name}"}}"#), + format!( + r##"{{ + "name": "{plugin_name}", + "interface": {{ + "displayName": "{plugin_name} display", + "brandColor": "#3B82F6", + "composerIcon": "./assets/icon.png" + }} +}}"## + ), )?; let skill_dir = plugin_root.join("skills").join(skill_name); @@ -119,9 +136,17 @@ fn write_cached_remote_plugin_with_skill( ) -> Result { let plugin_root = codex_home.join("plugins/cache/chatgpt-global/linear/local"); std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::create_dir_all(plugin_root.join("assets"))?; + std::fs::write(plugin_root.join("assets/linear.png"), &[] as &[u8])?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"linear"}"#, + r##"{ + "name": "linear", + "interface": { + "displayName": "Linear", + "composerIcon": "./assets/linear.png" + } +}"##, )?; let skill_dir = plugin_root.join("skills/triage-issues"); @@ -134,6 +159,36 @@ fn write_cached_remote_plugin_with_skill( Ok(skill_path) } +fn write_cached_local_plugin_with_skill( + codex_home: &std::path::Path, +) -> Result<(std::path::PathBuf, std::path::PathBuf)> { + let plugin_root = codex_home.join("plugins/cache/local-marketplace/demo-plugin/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::create_dir_all(plugin_root.join("assets"))?; + let icon_path = plugin_root.join("assets/icon.png"); + std::fs::write(&icon_path, &[] as &[u8])?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r##"{ + "name": "demo-plugin", + "interface": { + "displayName": "Demo Plugin", + "brandColor": "#3B82F6", + "composerIcon": "./assets/icon.png" + } +}"##, + )?; + + let skill_dir = plugin_root.join("skills/plugin-skill"); + std::fs::create_dir_all(&skill_dir)?; + let skill_path = skill_dir.join("SKILL.md"); + std::fs::write( + &skill_path, + "---\nname: plugin-skill\ndescription: plugin skill description\n---\n\n# Body\n", + )?; + Ok((skill_path, icon_path)) +} + #[tokio::test] async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result<()> { let codex_home = TempDir::new()?; @@ -311,6 +366,19 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result std::fs::canonicalize(skill.path.as_path())?, expected_skill_path ); + assert_eq!(skill.provenance, SkillProvenance::OpenaiMarketplace); + let plugin = skill.plugin.as_ref().expect("expected plugin metadata"); + assert_eq!(plugin.id, "linear@chatgpt-global"); + assert_eq!(plugin.marketplace_name, "chatgpt-global"); + assert_eq!(plugin.display_name.as_deref(), Some("Linear")); + assert_eq!( + plugin.composer_icon, + Some(AbsolutePathBuf::try_from(std::fs::canonicalize( + codex_home + .path() + .join("plugins/cache/chatgpt-global/linear/local/assets/linear.png") + )?)?) + ); assert_eq!(skill.enabled, true); Ok(()) } @@ -322,9 +390,10 @@ async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disable let server = MockServer::start().await; write_skill(&codex_home, "home-skill")?; write_plugin_with_skill(repo_root.path(), "demo-plugin", "plugin-skill")?; - write_plugins_enabled_config_with_base_url( + write_plugins_enabled_config_with_enabled_plugin( codex_home.path(), &format!("{}/backend-api/", server.uri()), + "demo-plugin@local-marketplace", )?; write_chatgpt_auth( codex_home.path(), @@ -380,6 +449,62 @@ async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disable Ok(()) } +#[tokio::test] +async fn skills_list_includes_plugin_metadata_for_plugin_skills() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let server = MockServer::start().await; + let (expected_skill_path, expected_icon_path) = + write_cached_local_plugin_with_skill(codex_home.path())?; + write_plugins_enabled_config_with_enabled_plugin( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + "demo-plugin@local-marketplace", + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![cwd.path().to_path_buf()], + force_reload: true, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(response)?; + assert_eq!(data.len(), 1); + let skill = data[0] + .skills + .iter() + .find(|skill| skill.name == "demo-plugin:plugin-skill") + .expect("expected plugin skill"); + assert_eq!( + std::fs::canonicalize(skill.path.as_path())?, + std::fs::canonicalize(expected_skill_path)? + ); + assert_eq!(skill.scope, codex_app_server_protocol::SkillScope::User); + assert_eq!(skill.provenance, SkillProvenance::CustomMarketplace); + let plugin = skill.plugin.as_ref().expect("expected plugin metadata"); + assert_eq!(plugin.id, "demo-plugin@local-marketplace"); + assert_eq!(plugin.name, "demo-plugin"); + assert_eq!(plugin.marketplace_name, "local-marketplace"); + assert_eq!(plugin.display_name.as_deref(), Some("Demo Plugin")); + assert_eq!(plugin.brand_color.as_deref(), Some("#3B82F6")); + assert_eq!( + plugin.composer_icon, + Some(AbsolutePathBuf::try_from(std::fs::canonicalize( + expected_icon_path + )?)?) + ); + Ok(()) +} + #[tokio::test] async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index f348a3414df1..fa2f46cd2ffe 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -26,6 +26,7 @@ use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginHookSource; use codex_plugin::PluginId; use codex_plugin::PluginIdError; +use codex_plugin::PluginInterfaceSummary; use codex_plugin::PluginLoadOutcome; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::Product; @@ -516,6 +517,7 @@ async fn load_plugin( config_name, manifest_name: None, manifest_description: None, + manifest_interface: None, root, enabled: plugin.enabled, skill_roots: Vec::new(), @@ -566,6 +568,15 @@ async fn load_plugin( .map(str::to_string) .or_else(|| Some(manifest.name.clone())); loaded_plugin.manifest_description = manifest.description.clone(); + loaded_plugin.manifest_interface = + manifest + .interface + .as_ref() + .map(|interface| PluginInterfaceSummary { + brand_color: interface.brand_color.clone(), + composer_icon: interface.composer_icon.clone(), + logo: interface.logo.clone(), + }); loaded_plugin.skill_roots = plugin_skill_roots(&plugin_root, manifest_paths); let resolved_skills = load_plugin_skills( &plugin_root, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index e1c0f1b12156..32edf5743b55 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -205,6 +205,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { manifest_description: Some( "Plugin that includes the sample MCP server and Skills".to_string(), ), + manifest_interface: None, root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(), enabled: true, skill_roots: vec![plugin_root.join("skills").abs()], @@ -855,6 +856,7 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions config_name: "sample@test".to_string(), manifest_name: None, manifest_description: None, + manifest_interface: None, root: AbsolutePathBuf::try_from(plugin_root).unwrap(), enabled: false, skill_roots: Vec::new(), @@ -974,6 +976,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { config_name: config_name.to_string(), manifest_name: Some(manifest_name.to_string()), manifest_description: None, + manifest_interface: None, root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), enabled: true, skill_roots: Vec::new(), diff --git a/codex-rs/plugin/src/lib.rs b/codex-rs/plugin/src/lib.rs index 92a2ace21ab5..a06ced6b3b54 100644 --- a/codex-rs/plugin/src/lib.rs +++ b/codex-rs/plugin/src/lib.rs @@ -10,6 +10,7 @@ use codex_config::HookEventsToml; use codex_utils_absolute_path::AbsolutePathBuf; pub use load_outcome::EffectiveSkillRoots; pub use load_outcome::LoadedPlugin; +pub use load_outcome::PluginInterfaceSummary; pub use load_outcome::PluginLoadOutcome; pub use load_outcome::prompt_safe_plugin_description; pub use plugin_id::PluginId; diff --git a/codex-rs/plugin/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs index c76697366f01..eb65458e02a7 100644 --- a/codex-rs/plugin/src/load_outcome.rs +++ b/codex-rs/plugin/src/load_outcome.rs @@ -16,6 +16,7 @@ pub struct LoadedPlugin { pub config_name: String, pub manifest_name: Option, pub manifest_description: Option, + pub manifest_interface: Option, pub root: AbsolutePathBuf, pub enabled: bool, pub skill_roots: Vec, @@ -28,6 +29,13 @@ pub struct LoadedPlugin { pub error: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginInterfaceSummary { + pub brand_color: Option, + pub composer_icon: Option, + pub logo: Option, +} + impl LoadedPlugin { pub fn is_active(&self) -> bool { self.enabled && self.error.is_none() @@ -219,6 +227,7 @@ mod tests { config_name: config_name.to_string(), manifest_name: None, manifest_description: None, + manifest_interface: None, root: test_path(config_name), enabled: true, skill_roots,