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 1009e4c1da..6fc8093354 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 @@ -16105,6 +16105,18 @@ }, "type": "array" }, + "onboardingSkill": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SkillSummary" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The declared onboarding skill, when the plugin and visible skill are enabled." + }, "scheduledTasks": { "items": { "$ref": "#/definitions/v2/ScheduledTaskSummary" 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 d122965b2f..8e1a7d1d8b 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 @@ -12157,6 +12157,18 @@ }, "type": "array" }, + "onboardingSkill": { + "anyOf": [ + { + "$ref": "#/definitions/SkillSummary" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The declared onboarding skill, when the plugin and visible skill are enabled." + }, "scheduledTasks": { "items": { "$ref": "#/definitions/ScheduledTaskSummary" diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 71d810b707..58b4f0c11a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -194,6 +194,18 @@ }, "type": "array" }, + "onboardingSkill": { + "anyOf": [ + { + "$ref": "#/definitions/SkillSummary" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The declared onboarding skill, when the plugin and visible skill are enabled." + }, "scheduledTasks": { "items": { "$ref": "#/definitions/ScheduledTaskSummary" diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index d1767064f8..3dcc2f63f8 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 69572ad7e6..9737584db1 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts index d4bf3f8261..1836b6b692 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts @@ -9,4 +9,8 @@ import type { PluginSummary } from "./PluginSummary"; import type { ScheduledTaskSummary } from "./ScheduledTaskSummary"; import type { SkillSummary } from "./SkillSummary"; -export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, shareUrl: string | null, description: string | null, skills: Array, hooks: Array, apps: Array, appTemplates: Array, mcpServers: Array, scheduledTasks: Array | null, }; +export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, shareUrl: string | null, description: string | null, skills: Array, +/** + * The declared onboarding skill, when the plugin and visible skill are enabled. + */ +onboardingSkill: SkillSummary | null, hooks: Array, apps: Array, appTemplates: Array, mcpServers: Array, scheduledTasks: Array | null, }; 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 6aa573d997..c203bd320a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -761,6 +761,9 @@ pub struct PluginDetail { pub share_url: Option, pub description: Option, pub skills: Vec, + /// The declared onboarding skill, when the plugin and visible skill are enabled. + #[serde(default)] + pub onboarding_skill: Option, pub hooks: Vec, pub apps: Vec, pub app_templates: Vec, diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index ae98b0a32b..a59627b34b 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -65,12 +65,12 @@ pub(crate) struct PluginRequestProcessor { Arc, } -fn plugin_skills_to_info( - skills: &[codex_skills::SkillMetadata], +fn plugin_skills_to_info<'a>( + skills: impl IntoIterator, disabled_skill_paths: &HashSet, ) -> Vec { skills - .iter() + .into_iter() .map(|skill| SkillSummary { name: skill.name.clone(), description: skill.description.clone(), @@ -1098,17 +1098,23 @@ impl PluginRequestProcessor { &outcome.plugin.app_category_by_id, ) .await; - let visible_skills = outcome - .plugin - .skills - .iter() - .filter(|skill| { - skill.matches_product_restriction_for_product( - self.thread_manager.session_source().restriction_product(), - ) - }) - .cloned() - .collect::>(); + let visible_skills = outcome.plugin.skills.iter().filter(|skill| { + skill.matches_product_restriction_for_product( + self.thread_manager.session_source().restriction_product(), + ) + }); + let skills = + plugin_skills_to_info(visible_skills, &outcome.plugin.disabled_skill_paths); + let onboarding_skill = if outcome.plugin.enabled + && let Some(path) = outcome.plugin.onboarding_skill.as_ref() + { + skills + .iter() + .find(|skill| skill.enabled && skill.path.as_ref() == Some(path)) + .cloned() + } else { + None + }; PluginDetail { marketplace_name: outcome.marketplace_name, marketplace_path: outcome.marketplace_path, @@ -1135,10 +1141,8 @@ impl PluginRequestProcessor { }, share_url: None, description: outcome.plugin.description, - skills: plugin_skills_to_info( - &visible_skills, - &outcome.plugin.disabled_skill_paths, - ), + skills, + onboarding_skill, hooks: outcome .plugin .hooks @@ -2247,24 +2251,38 @@ fn remote_plugin_detail_to_info( }) .collect(); + let skills = detail + .skills + .into_iter() + .map(|skill| SkillSummary { + name: skill.name, + description: skill.description, + short_description: skill.short_description, + interface: skill.interface, + path: None, + enabled: skill.enabled, + }) + .collect::>(); + let onboarding_skill = if detail.summary.enabled + && detail.summary.availability == PluginAvailability::Available + && let Some(name) = detail.onboarding_skill_name.as_ref() + { + skills + .iter() + .find(|skill| skill.enabled && &skill.name == name) + .cloned() + } else { + None + }; + PluginDetail { marketplace_name: detail.marketplace_name, marketplace_path: None, summary: remote_plugin_summary_to_info(detail.summary), share_url: detail.share_url, description: detail.description, - skills: detail - .skills - .into_iter() - .map(|skill| SkillSummary { - name: skill.name, - description: skill.description, - short_description: skill.short_description, - interface: skill.interface, - path: None, - enabled: skill.enabled, - }) - .collect(), + skills, + onboarding_skill, hooks: Vec::new(), apps, app_templates, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 9883ce79b2..4ee7b2d029 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -40,11 +40,13 @@ use codex_app_server_protocol::ScheduledTaskSchedule; use codex_app_server_protocol::ScheduledTaskSummary; use codex_app_server_protocol::ScheduledTaskWeekday; use codex_app_server_protocol::SkillInterface; +use codex_app_server_protocol::SkillSummary; use codex_config::types::AuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; +use test_case::test_case; use tokio::net::TcpListener; use tokio::task::JoinHandle; use tokio::time::timeout; @@ -59,6 +61,197 @@ use wiremock::matchers::query_param; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +#[test_case(Some("skills/setup/SKILL.md"), true, true, true; "enabled matching skill")] +#[test_case(Some("skills/setup/SKILL.md"), false, true, false; "disabled plugin")] +#[test_case(Some("skills/setup/SKILL.md"), true, false, false; "disabled skill")] +#[test_case(None, true, true, false; "missing declaration")] +#[test_case(Some("skills/missing/SKILL.md"), true, true, false; "unmatched declaration")] +#[tokio::test] +async fn plugin_read_selects_local_onboarding_skill( + onboarding_path: Option<&str>, + plugin_enabled: bool, + skill_enabled: bool, + expect_onboarding: bool, +) -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let plugin_root = repo_root.path().join("demo-plugin"); + write_plugin_marketplace( + repo_root.path(), + "codex-curated", + "demo-plugin", + "./demo-plugin", + )?; + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + let mut manifest = json!({ "name": "demo-plugin" }); + if let Some(onboarding_path) = onboarding_path { + manifest["extensions"] = json!({ + "com.openai": { "onboardingSkill": onboarding_path } + }); + } + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + serde_json::to_vec(&manifest)?, + )?; + for name in ["other", "setup"] { + std::fs::create_dir_all(plugin_root.join("skills").join(name))?; + std::fs::write( + plugin_root.join("skills").join(name).join("SKILL.md"), + format!("---\nname: {name}\ndescription: Run {name}\n---\n"), + )?; + } + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"[features] +plugins = true + +[plugins."demo-plugin@codex-curated"] +enabled = {plugin_enabled} + +[[skills.config]] +name = "demo-plugin:setup" +enabled = {skill_enabled} +"# + ), + )?; + write_installed_plugin(&codex_home, "codex-curated", "demo-plugin")?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build_initialized_with_timeout(DEFAULT_TIMEOUT) + .await?; + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + )?), + remote_marketplace_name: None, + plugin_name: "demo-plugin".to_string(), + }) + .await?; + let response: PluginReadResponse = + timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; + + let setup_skill = SkillSummary { + name: "demo-plugin:setup".to_string(), + description: "Run setup".to_string(), + short_description: None, + interface: None, + path: Some(AbsolutePathBuf::try_from(std::fs::canonicalize( + plugin_root.join("skills/setup/SKILL.md"), + )?)?), + enabled: skill_enabled, + }; + assert_eq!(response.plugin.summary.enabled, plugin_enabled); + assert!(response.plugin.skills.contains(&setup_skill)); + assert_eq!( + response.plugin.onboarding_skill, + expect_onboarding.then_some(setup_skill) + ); + Ok(()) +} + +#[test_case(Some("setup"), true, true, "AVAILABLE", true; "enabled matching skill")] +#[test_case(Some("setup"), false, true, "AVAILABLE", false; "disabled plugin")] +#[test_case(Some("setup"), true, false, "AVAILABLE", false; "disabled skill")] +#[test_case(None, true, true, "AVAILABLE", false; "missing declaration")] +#[test_case(Some("missing"), true, true, "AVAILABLE", false; "unmatched declaration")] +#[test_case(Some("setup"), true, true, "DISABLED_BY_ADMIN", false; "unavailable plugin")] +#[tokio::test] +async fn plugin_read_selects_remote_onboarding_skill( + onboarding_name: Option<&str>, + plugin_enabled: bool, + skill_enabled: bool, + status: &str, + expect_onboarding: bool, +) -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + let mut plugin = json!({ + "id": "plugins_123", + "name": "demo-plugin", + "scope": "GLOBAL", + "status": status, + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Demo Plugin", + "description": "Onboarding example", + "interface": {}, + "skills": [ + { "name": "other", "description": "Run other" }, + { "name": "setup", "description": "Run setup" } + ] + } + }); + if let Some(onboarding_name) = onboarding_name { + plugin["release"]["onboarding_skill_name"] = json!(onboarding_name); + } + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/plugins_123")) + .respond_with(ResponseTemplate::new(200).set_body_json(&plugin)) + .mount(&server) + .await; + plugin["enabled"] = json!(plugin_enabled); + plugin["disabled_skill_names"] = if skill_enabled { + json!([]) + } else { + json!(["setup"]) + }; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [plugin], + "pagination": { "limit": 50, "next_page_token": null } + }))) + .mount(&server) + .await; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build_initialized_with_timeout(DEFAULT_TIMEOUT) + .await?; + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated-remote".to_string()), + plugin_name: "plugins_123".to_string(), + }) + .await?; + let response: PluginReadResponse = + timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; + + let setup_skill = SkillSummary { + name: "setup".to_string(), + description: "Run setup".to_string(), + short_description: None, + interface: None, + path: None, + enabled: skill_enabled, + }; + assert_eq!(response.plugin.summary.enabled, plugin_enabled); + assert!(response.plugin.skills.contains(&setup_skill)); + assert_eq!( + response.plugin.onboarding_skill, + expect_onboarding.then_some(setup_skill) + ); + Ok(()) +} + #[tokio::test] async fn plugin_read_rejects_missing_read_source() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core-plugins/src/agent_plugin_manifest.rs b/codex-rs/core-plugins/src/agent_plugin_manifest.rs index 4f68f842f3..4b9d1e6356 100644 --- a/codex-rs/core-plugins/src/agent_plugin_manifest.rs +++ b/codex-rs/core-plugins/src/agent_plugin_manifest.rs @@ -5,6 +5,7 @@ use super::RawPluginManifestPaths; use super::UriPluginManifest; use super::compatibility_json_error; use super::parse_legacy_plugin_manifest_uri; +use super::resolve_openai_onboarding_skill; use super::resolve_raw_plugin_manifest; use codex_utils_path_uri::PathUri; use codex_utils_plugins::AGENT_PLUGIN_SCHEMA_PREFIX; @@ -91,7 +92,7 @@ pub(super) fn parse_agent_plugin_manifest_uri( .and_then(|extensions| extensions.get(CODEX_AGENT_PLUGIN_EXTENSION_NAMESPACE)) .and_then(|extension| { if extension.is_object() { - Some(serde_json::to_string(extension)) + Some(extension) } else { tracing::warn!( path = %manifest_path, @@ -100,8 +101,7 @@ pub(super) fn parse_agent_plugin_manifest_uri( ); None } - }) - .transpose()?; + }); for field in [ "version", "description", @@ -126,6 +126,9 @@ pub(super) fn parse_agent_plugin_manifest_uri( } } + let onboarding_skill = resolve_openai_onboarding_skill(plugin_root, codex_extension); + let codex_extension = codex_extension.map(serde_json::to_string).transpose()?; + let raw = serde_json::from_value::(JsonValue::Object(object))?; if !SUPPORTED_AGENT_PLUGIN_SCHEMA_URIS.contains(&raw.schema.as_str()) { let message = if raw.schema.starts_with(AGENT_PLUGIN_SCHEMA_PREFIX) { @@ -178,13 +181,9 @@ pub(super) fn parse_agent_plugin_manifest_uri( }, )?; - if let Some(extension_contents) = codex_extension.as_deref() { - apply_codex_agent_plugin_extension( - &mut resolved, - plugin_root, - manifest_path, - extension_contents, - )?; + if let Some(extension) = codex_extension.as_deref() { + apply_codex_agent_plugin_extension(&mut resolved, plugin_root, manifest_path, extension)?; + resolved.paths.onboarding_skill = onboarding_skill; } else if let Some((overlay_path, overlay_contents)) = overlay { apply_codex_agent_plugin_extension( &mut resolved, @@ -210,6 +209,7 @@ fn apply_codex_agent_plugin_extension( let extension = parse_legacy_plugin_manifest_uri(plugin_root, source_path, contents)?; resolved.paths.apps = extension.paths.apps; resolved.paths.hooks = extension.paths.hooks; + resolved.paths.onboarding_skill = extension.paths.onboarding_skill; if extension.interface.is_some() { resolved.interface = extension.interface; } diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 659309f5a7..8694817c4c 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -449,6 +449,8 @@ pub struct PluginDetail { pub enabled: bool, pub skills: Vec, pub disabled_skill_paths: HashSet, + /// Packaged onboarding path; callers apply visibility and enablement. + pub onboarding_skill: Option, pub hooks: Vec, pub apps: Vec, pub app_category_by_id: HashMap, @@ -2598,6 +2600,7 @@ impl PluginsManager { enabled: plugin.enabled, skills: Vec::new(), disabled_skill_paths: HashSet::new(), + onboarding_skill: None, hooks: Vec::new(), apps: Vec::new(), app_category_by_id: HashMap::new(), @@ -2677,6 +2680,18 @@ impl PluginsManager { ) .await .resolve(&skill_config_rules); + let onboarding_skill = manifest.paths.onboarding_skill.as_ref().and_then(|path| { + let plugin_root = source_path.canonicalize().ok()?; + let path = path.canonicalize().ok()?; + if !path.as_path().starts_with(plugin_root.as_path()) { + return None; + } + resolved_skills + .skills + .iter() + .any(|skill| skill.path_to_skills_md == path) + .then_some(path) + }); let plugin_data_root = self.store.plugin_data_root(&plugin_id); let (hook_sources, _hook_load_warnings) = if manifest_format == PluginManifestFormat::Legacy { @@ -2742,6 +2757,7 @@ impl PluginsManager { enabled: plugin.enabled, skills: resolved_skills.skills, disabled_skill_paths: resolved_skills.disabled_skill_paths, + onboarding_skill, hooks, apps, app_category_by_id, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 17e6271d40..fbd1501e76 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -2378,6 +2378,7 @@ async fn load_plugin_skills_dedupes_overlapping_manifest_roots() { description: None, keywords: Vec::new(), paths: crate::manifest::PluginManifestPaths { + onboarding_skill: None, skills: vec![ plugin_root.join("skills"), plugin_root.join("skills/abc"), diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index 40d724734b..39b9b1c93c 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -65,6 +65,8 @@ struct RawPluginManifest { hooks: Option, #[serde(default)] interface: Option, + #[serde(default)] + extensions: JsonValue, } #[derive(Deserialize)] @@ -302,6 +304,7 @@ fn resolve_raw_plugin_manifest( apps, hooks, interface, + extensions, } = raw; let name = plugin_root .basename() @@ -394,6 +397,10 @@ fn resolve_raw_plugin_manifest( keywords, paths: codex_plugin::manifest::PluginManifestPaths { skills: resolve_manifest_paths(plugin_root, "skills", skills.as_ref()), + onboarding_skill: resolve_openai_onboarding_skill( + plugin_root, + extensions.get("com.openai"), + ), mcp_servers: resolve_manifest_mcp_servers(plugin_root, mcp_servers), apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()), hooks: resolve_manifest_hooks(plugin_root, hooks), @@ -557,6 +564,22 @@ fn warn_invalid_default_prompt(manifest_path: &str, field: &str, message: &str) tracing::warn!(path = %manifest_path, "ignoring {field}: {message}"); } +fn resolve_openai_onboarding_skill( + plugin_root: &PathUri, + extension: Option<&JsonValue>, +) -> Option { + let path = extension + .and_then(|extension| extension.get("onboardingSkill")) + .and_then(JsonValue::as_str)?; + // This extension accepts relative paths with or without the legacy `./` prefix. + let path = format!("./{}", path.strip_prefix("./").unwrap_or(path)); + resolve_manifest_path( + plugin_root, + "extensions[\"com.openai\"].onboardingSkill", + Some(&path), + ) +} + fn json_value_type(value: &JsonValue) -> &'static str { match value { JsonValue::Null => "null", @@ -996,6 +1019,7 @@ mod tests { description: None, keywords: Vec::new(), paths: PluginManifestPaths { + onboarding_skill: None, skills: vec![plugin_root.join("skills").expect("skills URI")], mcp_servers: Some(PluginManifestMcpServers::Path( plugin_root.join(".mcp.json").expect("MCP URI"), diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index b3ef42c8e9..d919e81135 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -97,6 +97,7 @@ impl MarketplacePluginManifestFallback { ) .ok()?; manifest.paths = crate::manifest::PluginManifestPaths { + onboarding_skill: None, skills: Vec::new(), mcp_servers: None, apps: None, diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 9170b4fce4..ee70af99c0 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -299,6 +299,7 @@ pub struct RemotePluginDetail { pub bundle_download_url: Option, pub app_manifest: Option, pub skills: Vec, + pub onboarding_skill_name: Option, pub app_ids: Vec, pub app_templates: Vec, pub mcp_servers: Vec, @@ -633,6 +634,8 @@ struct RemotePluginReleaseResponse { interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] skills: Vec, + #[serde(default)] + onboarding_skill_name: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] mcp_servers: Vec, scheduled_tasks: Option>, @@ -1485,6 +1488,7 @@ async fn build_remote_plugin_detail( bundle_download_url: plugin.release.bundle_download_url, app_manifest: plugin.release.app_manifest, skills, + onboarding_skill_name: plugin.release.onboarding_skill_name, app_ids, app_templates: plugin .release diff --git a/codex-rs/core-plugins/src/remote_tests.rs b/codex-rs/core-plugins/src/remote_tests.rs index 1dbc7b8b37..c1c0365d44 100644 --- a/codex-rs/core-plugins/src/remote_tests.rs +++ b/codex-rs/core-plugins/src/remote_tests.rs @@ -640,6 +640,7 @@ fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem { screenshot_urls: Vec::new(), }, skills: Vec::new(), + onboarding_skill_name: None, mcp_servers: Vec::new(), scheduled_tasks: None, }, diff --git a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs index b1f95c9948..b396ed74aa 100644 --- a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs +++ b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs @@ -422,6 +422,7 @@ fn resolved_plugin( description: None, keywords: Vec::new(), paths: PluginManifestPaths { + onboarding_skill: None, skills: Vec::new(), mcp_servers, apps: None, diff --git a/codex-rs/plugin/src/manifest.rs b/codex-rs/plugin/src/manifest.rs index 89cfd8d0b6..30e6cda173 100644 --- a/codex-rs/plugin/src/manifest.rs +++ b/codex-rs/plugin/src/manifest.rs @@ -18,6 +18,7 @@ pub struct PluginManifest { #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginManifestPaths { pub skills: Vec, + pub onboarding_skill: Option, pub mcp_servers: Option>, pub apps: Option, pub hooks: Option>, @@ -105,6 +106,7 @@ impl PluginManifest { } = self; let PluginManifestPaths { skills, + onboarding_skill, mcp_servers, apps, hooks, @@ -181,6 +183,7 @@ impl PluginManifest { .into_iter() .map(&mut map) .collect::, _>>()?, + onboarding_skill: onboarding_skill.map(&mut map).transpose()?, mcp_servers, apps: apps.map(&mut map).transpose()?, hooks, diff --git a/codex-rs/plugin/src/provider_tests.rs b/codex-rs/plugin/src/provider_tests.rs index 7f9e70d330..ecc75234fa 100644 --- a/codex-rs/plugin/src/provider_tests.rs +++ b/codex-rs/plugin/src/provider_tests.rs @@ -43,6 +43,7 @@ fn environment_descriptor_binds_every_manifest_resource() { description: None, keywords: Vec::new(), paths: PluginManifestPaths { + onboarding_skill: None, skills: vec![path_uri(&skills)], mcp_servers: Some(PluginManifestMcpServers::Path(path_uri(&mcp_servers))), apps: Some(path_uri(&apps)), @@ -77,6 +78,7 @@ fn environment_descriptor_binds_every_manifest_resource() { description: None, keywords: Vec::new(), paths: PluginManifestPaths { + onboarding_skill: None, skills: vec![resource("executor-1", &skills)], mcp_servers: Some(PluginManifestMcpServers::Path(resource( "executor-1", @@ -109,6 +111,7 @@ fn environment_descriptor_rejects_resources_outside_package_root() { description: None, keywords: Vec::new(), paths: PluginManifestPaths { + onboarding_skill: None, skills: Vec::new(), mcp_servers: Some(PluginManifestMcpServers::Path(path_uri(&outside))), apps: None, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 86d44c9eb7..64fec22726 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1560,6 +1560,7 @@ pub(super) fn plugins_test_detail( mcp_servers: &[&str], ) -> PluginDetail { PluginDetail { + onboarding_skill: None, marketplace_name: "ChatGPT Marketplace".to_string(), marketplace_path: Some(plugins_test_absolute_path("marketplaces/chatgpt")), summary, @@ -1612,6 +1613,7 @@ pub(super) fn plugins_test_remote_detail( description: Option<&str>, ) -> PluginDetail { PluginDetail { + onboarding_skill: None, marketplace_name: marketplace_name.to_string(), marketplace_path: None, summary, diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 162d0d1ba3..e30f48a91b 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -870,6 +870,7 @@ async fn plugin_detail_unmaterialized_default_uses_remote_install_path() { cwd.to_path_buf(), Ok(PluginReadResponse { plugin: PluginDetail { + onboarding_skill: None, marketplace_name: "workspace-shared-with-me-private".to_string(), marketplace_path: None, summary, diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 8049043af4..b5707dd8ca 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -11574,6 +11574,13 @@ class PluginDetail(BaseModel): marketplace_name: Annotated[str, Field(alias="marketplaceName")] marketplace_path: Annotated[AbsolutePathBuf | None, Field(alias="marketplacePath")] = None mcp_servers: Annotated[list[str], Field(alias="mcpServers")] + onboarding_skill: Annotated[ + SkillSummary | None, + Field( + alias="onboardingSkill", + description="The declared onboarding skill, when the plugin and visible skill are enabled.", + ), + ] = None scheduled_tasks: Annotated[list[ScheduledTaskSummary] | None, Field(alias="scheduledTasks")] = ( None )