From ca650561d6655d29494b06f08bf89cdc943e7664 Mon Sep 17 00:00:00 2001 From: alexsong-oai Date: Tue, 14 Apr 2026 19:39:10 -0700 Subject: [PATCH] support plugins in external agent config migration (#17855) --- .../schema/json/ClientRequest.json | 43 +++ .../codex_app_server_protocol.schemas.json | 43 +++ .../codex_app_server_protocol.v2.schemas.json | 43 +++ .../v2/ExternalAgentConfigDetectResponse.json | 43 +++ .../v2/ExternalAgentConfigImportParams.json | 43 +++ .../v2/ExternalAgentConfigMigrationItem.ts | 3 +- .../ExternalAgentConfigMigrationItemType.ts | 2 +- .../schema/typescript/v2/MigrationDetails.ts | 6 + .../schema/typescript/v2/PluginsMigration.ts | 5 + .../schema/typescript/v2/index.ts | 2 + .../app-server-protocol/src/protocol/v2.rs | 56 +++ codex-rs/app-server/README.md | 4 +- .../src/external_agent_config_api.rs | 33 ++ codex-rs/core/src/external_agent_config.rs | 345 +++++++++++++++++- .../core/src/external_agent_config_tests.rs | 184 +++++++++- 15 files changed, 822 insertions(+), 33 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/MigrationDetails.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginsMigration.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index a2659bd23..113d82c94 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -605,6 +605,16 @@ "description": { "type": "string" }, + "details": { + "anyOf": [ + { + "$ref": "#/definitions/MigrationDetails" + }, + { + "type": "null" + } + ] + }, "itemType": { "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" } @@ -620,6 +630,7 @@ "AGENTS_MD", "CONFIG", "SKILLS", + "PLUGINS", "MCP_SERVER_CONFIG" ], "type": "string" @@ -1359,6 +1370,20 @@ } ] }, + "MigrationDetails": { + "properties": { + "plugins": { + "items": { + "$ref": "#/definitions/PluginsMigration" + }, + "type": "array" + } + }, + "required": [ + "plugins" + ], + "type": "object" + }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -1478,6 +1503,24 @@ ], "type": "object" }, + "PluginsMigration": { + "properties": { + "marketplaceName": { + "type": "string" + }, + "pluginNames": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "marketplaceName", + "pluginNames" + ], + "type": "object" + }, "ReadOnlyAccess": { "oneOf": [ { 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 49b0c19a3..d0ce368b7 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 @@ -7509,6 +7509,16 @@ "description": { "type": "string" }, + "details": { + "anyOf": [ + { + "$ref": "#/definitions/v2/MigrationDetails" + }, + { + "type": "null" + } + ] + }, "itemType": { "$ref": "#/definitions/v2/ExternalAgentConfigMigrationItemType" } @@ -7524,6 +7534,7 @@ "AGENTS_MD", "CONFIG", "SKILLS", + "PLUGINS", "MCP_SERVER_CONFIG" ], "type": "string" @@ -9551,6 +9562,20 @@ } ] }, + "MigrationDetails": { + "properties": { + "plugins": { + "items": { + "$ref": "#/definitions/v2/PluginsMigration" + }, + "type": "array" + } + }, + "required": [ + "plugins" + ], + "type": "object" + }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -10473,6 +10498,24 @@ "title": "PluginUninstallResponse", "type": "object" }, + "PluginsMigration": { + "properties": { + "marketplaceName": { + "type": "string" + }, + "pluginNames": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "marketplaceName", + "pluginNames" + ], + "type": "object" + }, "ProfileV2": { "additionalProperties": true, "properties": { 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 7c1844e6e..aca9b2388 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 @@ -4126,6 +4126,16 @@ "description": { "type": "string" }, + "details": { + "anyOf": [ + { + "$ref": "#/definitions/MigrationDetails" + }, + { + "type": "null" + } + ] + }, "itemType": { "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" } @@ -4141,6 +4151,7 @@ "AGENTS_MD", "CONFIG", "SKILLS", + "PLUGINS", "MCP_SERVER_CONFIG" ], "type": "string" @@ -6323,6 +6334,20 @@ } ] }, + "MigrationDetails": { + "properties": { + "plugins": { + "items": { + "$ref": "#/definitions/PluginsMigration" + }, + "type": "array" + } + }, + "required": [ + "plugins" + ], + "type": "object" + }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -7245,6 +7270,24 @@ "title": "PluginUninstallResponse", "type": "object" }, + "PluginsMigration": { + "properties": { + "marketplaceName": { + "type": "string" + }, + "pluginNames": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "marketplaceName", + "pluginNames" + ], + "type": "object" + }, "ProfileV2": { "additionalProperties": true, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectResponse.json index a73e515c2..ad8f0f9bd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectResponse.json @@ -13,6 +13,16 @@ "description": { "type": "string" }, + "details": { + "anyOf": [ + { + "$ref": "#/definitions/MigrationDetails" + }, + { + "type": "null" + } + ] + }, "itemType": { "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" } @@ -28,9 +38,42 @@ "AGENTS_MD", "CONFIG", "SKILLS", + "PLUGINS", "MCP_SERVER_CONFIG" ], "type": "string" + }, + "MigrationDetails": { + "properties": { + "plugins": { + "items": { + "$ref": "#/definitions/PluginsMigration" + }, + "type": "array" + } + }, + "required": [ + "plugins" + ], + "type": "object" + }, + "PluginsMigration": { + "properties": { + "marketplaceName": { + "type": "string" + }, + "pluginNames": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "marketplaceName", + "pluginNames" + ], + "type": "object" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json index 85af24959..4b7ac826c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json @@ -13,6 +13,16 @@ "description": { "type": "string" }, + "details": { + "anyOf": [ + { + "$ref": "#/definitions/MigrationDetails" + }, + { + "type": "null" + } + ] + }, "itemType": { "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" } @@ -28,9 +38,42 @@ "AGENTS_MD", "CONFIG", "SKILLS", + "PLUGINS", "MCP_SERVER_CONFIG" ], "type": "string" + }, + "MigrationDetails": { + "properties": { + "plugins": { + "items": { + "$ref": "#/definitions/PluginsMigration" + }, + "type": "array" + } + }, + "required": [ + "plugins" + ], + "type": "object" + }, + "PluginsMigration": { + "properties": { + "marketplaceName": { + "type": "string" + }, + "pluginNames": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "marketplaceName", + "pluginNames" + ], + "type": "object" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItem.ts index 45e5585a7..c9921ccbc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItem.ts @@ -2,9 +2,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType"; +import type { MigrationDetails } from "./MigrationDetails"; export type ExternalAgentConfigMigrationItem = { itemType: ExternalAgentConfigMigrationItemType, description: string, /** * Null or empty means home-scoped migration; non-empty means repo-scoped migration. */ -cwd: string | null, }; +cwd: string | null, details: MigrationDetails | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItemType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItemType.ts index c9bd160b1..dedc124f0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItemType.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigMigrationItemType.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ExternalAgentConfigMigrationItemType = "AGENTS_MD" | "CONFIG" | "SKILLS" | "MCP_SERVER_CONFIG"; +export type ExternalAgentConfigMigrationItemType = "AGENTS_MD" | "CONFIG" | "SKILLS" | "PLUGINS" | "MCP_SERVER_CONFIG"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MigrationDetails.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MigrationDetails.ts new file mode 100644 index 000000000..9305335d9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MigrationDetails.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 { PluginsMigration } from "./PluginsMigration"; + +export type MigrationDetails = { plugins: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginsMigration.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginsMigration.ts new file mode 100644 index 000000000..0dce06d9a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginsMigration.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 PluginsMigration = { marketplaceName: string, pluginNames: Array, }; 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 91a51737f..d432530b4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -198,6 +198,7 @@ export type { McpToolCallStatus } from "./McpToolCallStatus"; export type { MemoryCitation } from "./MemoryCitation"; export type { MemoryCitationEntry } from "./MemoryCitationEntry"; export type { MergeStrategy } from "./MergeStrategy"; +export type { MigrationDetails } from "./MigrationDetails"; export type { Model } from "./Model"; export type { ModelAvailabilityNux } from "./ModelAvailabilityNux"; export type { ModelListParams } from "./ModelListParams"; @@ -236,6 +237,7 @@ export type { PluginSource } from "./PluginSource"; export type { PluginSummary } from "./PluginSummary"; export type { PluginUninstallParams } from "./PluginUninstallParams"; export type { PluginUninstallResponse } from "./PluginUninstallResponse"; +export type { PluginsMigration } from "./PluginsMigration"; export type { ProfileV2 } from "./ProfileV2"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 8ea3f9623..0843241f3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -944,11 +944,33 @@ pub enum ExternalAgentConfigMigrationItemType { #[serde(rename = "SKILLS")] #[ts(rename = "SKILLS")] Skills, + #[serde(rename = "PLUGINS")] + #[ts(rename = "PLUGINS")] + Plugins, #[serde(rename = "MCP_SERVER_CONFIG")] #[ts(rename = "MCP_SERVER_CONFIG")] McpServerConfig, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginsMigration { + #[serde(rename = "marketplaceName")] + #[ts(rename = "marketplaceName")] + pub marketplace_name: String, + #[serde(rename = "pluginNames")] + #[ts(rename = "pluginNames")] + pub plugin_names: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MigrationDetails { + pub plugins: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -957,6 +979,7 @@ pub struct ExternalAgentConfigMigrationItem { pub description: String, /// Null or empty means home-scoped migration; non-empty means repo-scoped migration. pub cwd: Option, + pub details: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] @@ -6592,6 +6615,39 @@ mod tests { ); } + #[test] + fn external_agent_config_plugins_details_round_trip() { + let item: ExternalAgentConfigMigrationItem = serde_json::from_value(json!({ + "itemType": "PLUGINS", + "description": "Install supported plugins from Claude settings", + "cwd": absolute_path_string("repo"), + "details": { + "plugins": [ + { + "marketplaceName": "team-marketplace", + "pluginNames": ["asana"] + } + ] + } + })) + .expect("plugins migration item should deserialize"); + + assert_eq!( + item, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: "Install supported plugins from Claude settings".to_string(), + cwd: Some(PathBuf::from(absolute_path_string("repo"))), + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "team-marketplace".to_string(), + plugin_names: vec!["asana".to_string()], + }], + }), + } + ); + } + #[test] fn command_execution_request_approval_rejects_relative_additional_permission_paths() { let err = serde_json::from_value::(json!({ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 9715db92f..e00bbefc9 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -196,8 +196,8 @@ Example with notification opt-out: - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. - `config/read` — fetch the effective config on disk after resolving config layering. -- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home). -- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home). +- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin migration items may additionally include structured `details` grouping plugin ids under each detected marketplace name. +- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin `details` returned by detect. - `config/value/write` — write a single config key/value to the user's config.toml on disk. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. - `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly`. diff --git a/codex-rs/app-server/src/external_agent_config_api.rs b/codex-rs/app-server/src/external_agent_config_api.rs index 7cf0d65d7..3e20632db 100644 --- a/codex-rs/app-server/src/external_agent_config_api.rs +++ b/codex-rs/app-server/src/external_agent_config_api.rs @@ -6,6 +6,8 @@ use codex_app_server_protocol::ExternalAgentConfigImportResponse; use codex_app_server_protocol::ExternalAgentConfigMigrationItem; use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::MigrationDetails; +use codex_app_server_protocol::PluginsMigration; use codex_core::external_agent_config::ExternalAgentConfigDetectOptions; use codex_core::external_agent_config::ExternalAgentConfigMigrationItem as CoreMigrationItem; use codex_core::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType; @@ -51,12 +53,25 @@ impl ExternalAgentConfigApi { CoreMigrationItemType::AgentsMd => { ExternalAgentConfigMigrationItemType::AgentsMd } + CoreMigrationItemType::Plugins => { + ExternalAgentConfigMigrationItemType::Plugins + } CoreMigrationItemType::McpServerConfig => { ExternalAgentConfigMigrationItemType::McpServerConfig } }, description: migration_item.description, cwd: migration_item.cwd, + details: migration_item.details.map(|details| MigrationDetails { + plugins: details + .plugins + .into_iter() + .map(|plugin| PluginsMigration { + marketplace_name: plugin.marketplace_name, + plugin_names: plugin.plugin_names, + }) + .collect(), + }), }) .collect(), }) @@ -82,15 +97,33 @@ impl ExternalAgentConfigApi { ExternalAgentConfigMigrationItemType::AgentsMd => { CoreMigrationItemType::AgentsMd } + ExternalAgentConfigMigrationItemType::Plugins => { + CoreMigrationItemType::Plugins + } ExternalAgentConfigMigrationItemType::McpServerConfig => { CoreMigrationItemType::McpServerConfig } }, description: migration_item.description, cwd: migration_item.cwd, + details: migration_item.details.map(|details| { + codex_core::external_agent_config::MigrationDetails { + plugins: details + .plugins + .into_iter() + .map(|plugin| { + codex_core::external_agent_config::PluginsMigration { + marketplace_name: plugin.marketplace_name, + plugin_names: plugin.plugin_names, + } + }) + .collect(), + } + }), }) .collect(), ) + .await .map_err(map_io_error)?; Ok(ExternalAgentConfigImportResponse {}) diff --git a/codex-rs/core/src/external_agent_config.rs b/codex-rs/core/src/external_agent_config.rs index efa90c849..1699239ab 100644 --- a/codex-rs/core/src/external_agent_config.rs +++ b/codex-rs/core/src/external_agent_config.rs @@ -1,4 +1,11 @@ +use crate::plugins::MarketplaceAddRequest; +use crate::plugins::PluginId; +use crate::plugins::PluginInstallRequest; +use crate::plugins::PluginsManager; +use crate::plugins::add_marketplace; +use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value as JsonValue; +use std::collections::BTreeMap; use std::collections::HashSet; use std::ffi::OsString; use std::fs; @@ -9,6 +16,8 @@ use toml::Value as TomlValue; const EXTERNAL_AGENT_CONFIG_DETECT_METRIC: &str = "codex.external_agent_config.detect"; const EXTERNAL_AGENT_CONFIG_IMPORT_METRIC: &str = "codex.external_agent_config.import"; +// Installed marketplace roots always expose their manifest at this relative path. +const INSTALLED_MARKETPLACE_MANIFEST_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExternalAgentConfigDetectOptions { @@ -21,36 +30,57 @@ pub enum ExternalAgentConfigMigrationItemType { Config, Skills, AgentsMd, + Plugins, McpServerConfig, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginsMigration { + pub marketplace_name: String, + pub plugin_names: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MigrationDetails { + pub plugins: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct PluginImportOutcome { + succeeded_marketplaces: Vec, + succeeded_plugin_ids: Vec, + failed_marketplaces: Vec, + failed_plugin_ids: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExternalAgentConfigMigrationItem { pub item_type: ExternalAgentConfigMigrationItemType, pub description: String, pub cwd: Option, + pub details: Option, } #[derive(Clone)] pub struct ExternalAgentConfigService { codex_home: PathBuf, - claude_home: PathBuf, + external_agent_home: PathBuf, } impl ExternalAgentConfigService { pub fn new(codex_home: PathBuf) -> Self { - let claude_home = default_claude_home(); + let external_agent_home = default_external_agent_home(); Self { codex_home, - claude_home, + external_agent_home, } } #[cfg(test)] - fn new_for_test(codex_home: PathBuf, claude_home: PathBuf) -> Self { + fn new_for_test(codex_home: PathBuf, external_agent_home: PathBuf) -> Self { Self { codex_home, - claude_home, + external_agent_home, } } @@ -73,7 +103,10 @@ impl ExternalAgentConfigService { Ok(items) } - pub fn import(&self, migration_items: Vec) -> io::Result<()> { + pub async fn import( + &self, + migration_items: Vec, + ) -> io::Result<()> { for migration_item in migration_items { match migration_item.item_type { ExternalAgentConfigMigrationItemType::Config => { @@ -100,6 +133,35 @@ impl ExternalAgentConfigService { /*skills_count*/ None, ); } + ExternalAgentConfigMigrationItemType::Plugins => { + let service = self.clone(); + let cwd = migration_item.cwd; + let details = migration_item.details; + tokio::spawn(async move { + match service.import_plugins(cwd.as_deref(), details).await { + Ok(outcome) => { + tracing::info!( + succeeded_marketplaces = outcome.succeeded_marketplaces.len(), + succeeded_plugin_ids = outcome.succeeded_plugin_ids.len(), + failed_marketplaces = outcome.failed_marketplaces.len(), + failed_plugin_ids = outcome.failed_plugin_ids.len(), + "external agent config plugin import completed" + ); + } + Err(err) => { + tracing::warn!( + error = %err, + "external agent config plugin import failed" + ); + } + } + }); + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_IMPORT_METRIC, + ExternalAgentConfigMigrationItemType::Plugins, + /*skills_count*/ None, + ); + } ExternalAgentConfigMigrationItemType::McpServerConfig => {} } } @@ -114,17 +176,15 @@ impl ExternalAgentConfigService { ) -> io::Result<()> { let cwd = repo_root.map(Path::to_path_buf); let source_settings = repo_root.map_or_else( - || self.claude_home.join("settings.json"), + || self.external_agent_home.join("settings.json"), |repo_root| repo_root.join(".claude").join("settings.json"), ); + let settings = read_external_settings(&source_settings)?; let target_config = repo_root.map_or_else( || self.codex_home.join("config.toml"), |repo_root| repo_root.join(".codex").join("config.toml"), ); - if source_settings.is_file() { - let raw_settings = fs::read_to_string(&source_settings)?; - let settings: JsonValue = serde_json::from_str(&raw_settings) - .map_err(|err| invalid_data_error(err.to_string()))?; + if let Some(settings) = settings.as_ref() { let migrated = build_config_from_external(&settings)?; if !is_empty_toml_table(&migrated) { let mut should_include = true; @@ -149,6 +209,7 @@ impl ExternalAgentConfigService { target_config.display() ), cwd: cwd.clone(), + details: None, }); emit_migration_metric( EXTERNAL_AGENT_CONFIG_DETECT_METRIC, @@ -159,8 +220,15 @@ impl ExternalAgentConfigService { } } + self.detect_plugin_migration( + source_settings.as_path(), + cwd.clone(), + settings.as_ref(), + items, + ); + let source_skills = repo_root.map_or_else( - || self.claude_home.join("skills"), + || self.external_agent_home.join("skills"), |repo_root| repo_root.join(".claude").join("skills"), ); let target_skills = repo_root.map_or_else( @@ -177,6 +245,7 @@ impl ExternalAgentConfigService { target_skills.display() ), cwd: cwd.clone(), + details: None, }); emit_migration_metric( EXTERNAL_AGENT_CONFIG_DETECT_METRIC, @@ -188,7 +257,7 @@ impl ExternalAgentConfigService { let source_agents_md = if let Some(repo_root) = repo_root { find_repo_agents_md_source(repo_root)? } else { - let path = self.claude_home.join("CLAUDE.md"); + let path = self.external_agent_home.join("CLAUDE.md"); is_non_empty_text_file(&path)?.then_some(path) }; let target_agents_md = repo_root.map_or_else( @@ -206,6 +275,7 @@ impl ExternalAgentConfigService { target_agents_md.display() ), cwd, + details: None, }); emit_migration_metric( EXTERNAL_AGENT_CONFIG_DETECT_METRIC, @@ -224,6 +294,109 @@ impl ExternalAgentConfigService { .unwrap_or_else(|| PathBuf::from(".agents").join("skills")) } + fn detect_plugin_migration( + &self, + source_settings: &Path, + cwd: Option, + settings: Option<&JsonValue>, + items: &mut Vec, + ) { + let Some(plugin_details) = settings.and_then(extract_plugin_migration_details) else { + return; + }; + + items.push(ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: format!("Import enabled plugins from {}", source_settings.display()), + cwd, + details: Some(plugin_details), + }); + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_DETECT_METRIC, + ExternalAgentConfigMigrationItemType::Plugins, + /*skills_count*/ None, + ); + } + + async fn import_plugins( + &self, + cwd: Option<&Path>, + details: Option, + ) -> io::Result { + let Some(MigrationDetails { plugins }) = details else { + return Err(invalid_data_error( + "plugins migration item is missing details".to_string(), + )); + }; + let mut outcome = PluginImportOutcome::default(); + let plugins_manager = PluginsManager::new(self.codex_home.clone()); + for plugin_group in plugins { + let marketplace_name = plugin_group.marketplace_name.clone(); + let plugin_names = plugin_group.plugin_names; + let plugin_ids = plugin_names + .iter() + .map(|plugin_name| format!("{plugin_name}@{marketplace_name}")) + .collect::>(); + let import_source = + read_marketplace_import_source(cwd, &self.external_agent_home, &marketplace_name)?; + let Some(import_source) = import_source else { + outcome.failed_marketplaces.push(marketplace_name); + outcome.failed_plugin_ids.extend(plugin_ids); + continue; + }; + let request = MarketplaceAddRequest { + source: import_source.source, + ref_name: import_source.ref_name, + sparse_paths: Vec::new(), + }; + let add_marketplace_outcome = add_marketplace(self.codex_home.clone(), request).await; + let marketplace_path = match add_marketplace_outcome { + Ok(add_marketplace_outcome) => { + match AbsolutePathBuf::try_from( + add_marketplace_outcome + .installed_root + .join(INSTALLED_MARKETPLACE_MANIFEST_RELATIVE_PATH), + ) { + Ok(path) => { + outcome + .succeeded_marketplaces + .push(marketplace_name.clone()); + path + } + Err(_) => { + outcome.failed_marketplaces.push(marketplace_name); + outcome.failed_plugin_ids.extend(plugin_ids); + continue; + } + } + } + Err(_) => { + outcome.failed_marketplaces.push(marketplace_name); + outcome.failed_plugin_ids.extend(plugin_ids); + continue; + } + }; + for plugin_name in plugin_names { + match plugins_manager + .install_plugin(PluginInstallRequest { + plugin_name: plugin_name.clone(), + marketplace_path: marketplace_path.clone(), + }) + .await + { + Ok(_) => outcome + .succeeded_plugin_ids + .push(format!("{plugin_name}@{marketplace_name}")), + Err(_) => outcome + .failed_plugin_ids + .push(format!("{plugin_name}@{marketplace_name}")), + } + } + } + + Ok(outcome) + } + fn import_config(&self, cwd: Option<&Path>) -> io::Result<()> { let (source_settings, target_config) = if let Some(repo_root) = find_repo_root(cwd)? { ( @@ -234,7 +407,7 @@ impl ExternalAgentConfigService { return Ok(()); } else { ( - self.claude_home.join("settings.json"), + self.external_agent_home.join("settings.json"), self.codex_home.join("config.toml"), ) }; @@ -286,7 +459,7 @@ impl ExternalAgentConfigService { return Ok(0); } else { ( - self.claude_home.join("skills"), + self.external_agent_home.join("skills"), self.home_target_skills_dir(), ) }; @@ -326,7 +499,7 @@ impl ExternalAgentConfigService { return Ok(()); } else { ( - self.claude_home.join("CLAUDE.md"), + self.external_agent_home.join("CLAUDE.md"), self.codex_home.join("AGENTS.md"), ) }; @@ -345,7 +518,7 @@ impl ExternalAgentConfigService { } } -fn default_claude_home() -> PathBuf { +fn default_external_agent_home() -> PathBuf { if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) { return PathBuf::from(home).join(".claude"); } @@ -353,6 +526,139 @@ fn default_claude_home() -> PathBuf { PathBuf::from(".claude") } +fn read_external_settings(path: &Path) -> io::Result> { + if !path.is_file() { + return Ok(None); + } + + let raw_settings = fs::read_to_string(path)?; + let settings = + serde_json::from_str(&raw_settings).map_err(|err| invalid_data_error(err.to_string()))?; + Ok(Some(settings)) +} + +fn extract_plugin_migration_details(settings: &JsonValue) -> Option { + let mut plugins = BTreeMap::new(); + for plugin_id in collect_enabled_plugins(settings) { + let Ok(plugin_id) = PluginId::parse(&plugin_id) else { + continue; + }; + let plugin_group = plugins + .entry(plugin_id.marketplace_name.clone()) + .or_insert_with(|| PluginsMigration { + marketplace_name: plugin_id.marketplace_name.clone(), + plugin_names: Vec::new(), + }); + plugin_group.plugin_names.push(plugin_id.plugin_name); + } + + let plugins = plugins + .into_values() + .filter_map(|mut plugin_group| { + if plugin_group.plugin_names.is_empty() { + return None; + } + plugin_group.plugin_names.sort(); + Some(plugin_group) + }) + .collect::>(); + if plugins.is_empty() { + return None; + } + + Some(MigrationDetails { plugins }) +} + +fn collect_enabled_plugins(settings: &JsonValue) -> Vec { + let Some(enabled_plugins) = settings + .as_object() + .and_then(|settings| settings.get("enabledPlugins")) + .and_then(JsonValue::as_object) + else { + return Vec::new(); + }; + + enabled_plugins + .iter() + .filter_map(|(plugin_key, enabled)| { + if !enabled.as_bool().unwrap_or(false) { + return None; + } + PluginId::parse(plugin_key) + .ok() + .map(|plugin_id| plugin_id.as_key()) + }) + .collect() +} + +fn collect_marketplace_import_sources( + settings: &JsonValue, +) -> BTreeMap { + let Some(extra_known_marketplaces) = settings + .as_object() + .and_then(|settings| settings.get("extraKnownMarketplaces")) + .and_then(JsonValue::as_object) + else { + return BTreeMap::new(); + }; + + extra_known_marketplaces + .iter() + .filter_map(|(name, value)| { + let source_fields = if let Some(source) = value.get("source") + && source.is_object() + { + source.as_object()? + } else { + value.as_object()? + }; + let source = source_fields + .get("repo") + .or_else(|| source_fields.get("url")) + .or_else(|| source_fields.get("path")) + .or_else(|| value.get("source"))? + .as_str()? + .trim() + .to_string(); + if source.is_empty() { + return None; + } + + let ref_name = source_fields + .get("ref") + .or_else(|| value.get("ref")) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + + Some((name.clone(), MarketplaceImportSource { source, ref_name })) + }) + .collect() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct MarketplaceImportSource { + source: String, + ref_name: Option, +} + +fn read_marketplace_import_source( + cwd: Option<&Path>, + external_agent_home: &Path, + marketplace_name: &str, +) -> io::Result> { + let source_settings = cwd.map_or_else( + || external_agent_home.join("settings.json"), + |cwd| cwd.join(".claude").join("settings.json"), + ); + let Some(settings) = read_external_settings(&source_settings)? else { + return Ok(None); + }; + + Ok(collect_marketplace_import_sources(&settings).remove(marketplace_name)) +} + fn find_repo_root(cwd: Option<&Path>) -> io::Result> { let Some(cwd) = cwd.filter(|cwd| !cwd.as_os_str().is_empty()) else { return Ok(None); @@ -480,11 +786,11 @@ fn is_skill_md(path: &Path) -> bool { fn rewrite_and_copy_text_file(source: &Path, target: &Path) -> io::Result<()> { let source_contents = fs::read_to_string(source)?; - let rewritten = rewrite_claude_terms(&source_contents); + let rewritten = rewrite_external_agent_terms(&source_contents); fs::write(target, rewritten) } -fn rewrite_claude_terms(content: &str) -> String { +fn rewrite_external_agent_terms(content: &str) -> String { let mut rewritten = replace_case_insensitive_with_boundaries(content, "claude.md", "AGENTS.md"); for from in [ "claude code", @@ -662,6 +968,7 @@ fn migration_metric_tags( ExternalAgentConfigMigrationItemType::Config => "config", ExternalAgentConfigMigrationItemType::Skills => "skills", ExternalAgentConfigMigrationItemType::AgentsMd => "agents_md", + ExternalAgentConfigMigrationItemType::Plugins => "plugins", ExternalAgentConfigMigrationItemType::McpServerConfig => "mcp_server_config", }; let mut tags = vec![("migration_type", migration_type.to_string())]; diff --git a/codex-rs/core/src/external_agent_config_tests.rs b/codex-rs/core/src/external_agent_config_tests.rs index 7baadbec0..f2afab017 100644 --- a/codex-rs/core/src/external_agent_config_tests.rs +++ b/codex-rs/core/src/external_agent_config_tests.rs @@ -1,5 +1,6 @@ use super::*; use pretty_assertions::assert_eq; +use std::io; use tempfile::TempDir; fn fixture_paths() -> (TempDir, PathBuf, PathBuf) { @@ -13,6 +14,15 @@ fn service_for_paths(claude_home: PathBuf, codex_home: PathBuf) -> ExternalAgent ExternalAgentConfigService::new_for_test(codex_home, claude_home) } +fn github_plugin_details() -> MigrationDetails { + MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "acme-tools".to_string(), + plugin_names: vec!["formatter".to_string()], + }], + } +} + #[test] fn detect_home_lists_config_skills_and_agents_md() { let (_root, claude_home, codex_home) = fixture_paths(); @@ -44,6 +54,7 @@ fn detect_home_lists_config_skills_and_agents_md() { codex_home.join("config.toml").display() ), cwd: None, + details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Skills, @@ -53,6 +64,7 @@ fn detect_home_lists_config_skills_and_agents_md() { agents_skills.display() ), cwd: None, + details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, @@ -62,6 +74,7 @@ fn detect_home_lists_config_skills_and_agents_md() { codex_home.join("AGENTS.md").display() ), cwd: None, + details: None, }, ]; @@ -93,6 +106,7 @@ fn detect_repo_lists_agents_md_for_each_cwd() { repo_root.join("AGENTS.md").display(), ), cwd: Some(repo_root.clone()), + details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, @@ -102,14 +116,15 @@ fn detect_repo_lists_agents_md_for_each_cwd() { repo_root.join("AGENTS.md").display(), ), cwd: Some(repo_root), + details: None, }, ]; assert_eq!(items, expected); } -#[test] -fn import_home_migrates_supported_config_fields_skills_and_agents_md() { +#[tokio::test] +async fn import_home_migrates_supported_config_fields_skills_and_agents_md() { let (_root, claude_home, codex_home) = fixture_paths(); let agents_skills = codex_home .parent() @@ -134,18 +149,22 @@ fn import_home_migrates_supported_config_fields_skills_and_agents_md() { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: String::new(), cwd: None, + details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Config, description: String::new(), cwd: None, + details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Skills, description: String::new(), cwd: None, + details: None, }, ]) + .await .expect("import"); assert_eq!( @@ -164,8 +183,8 @@ fn import_home_migrates_supported_config_fields_skills_and_agents_md() { ); } -#[test] -fn import_home_skips_empty_config_migration() { +#[tokio::test] +async fn import_home_skips_empty_config_migration() { let (_root, claude_home, codex_home) = fixture_paths(); fs::create_dir_all(&claude_home).expect("create claude home"); fs::write( @@ -179,7 +198,9 @@ fn import_home_skips_empty_config_migration() { item_type: ExternalAgentConfigMigrationItemType::Config, description: String::new(), cwd: None, + details: None, }]) + .await .expect("import"); assert!(!codex_home.join("config.toml").exists()); @@ -239,8 +260,8 @@ fn detect_home_skips_skills_when_all_skill_directories_exist() { assert_eq!(items, Vec::::new()); } -#[test] -fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() { +#[tokio::test] +async fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() { let root = TempDir::new().expect("create tempdir"); let repo_root = root.path().join("repo-a"); let repo_with_existing_target = root.path().join("repo-b"); @@ -264,13 +285,16 @@ fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: String::new(), cwd: Some(repo_root.clone()), + details: None, }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: String::new(), cwd: Some(repo_with_existing_target.clone()), + details: None, }, ]) + .await .expect("import"); assert_eq!( @@ -284,8 +308,8 @@ fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() { ); } -#[test] -fn import_repo_agents_md_overwrites_empty_targets() { +#[tokio::test] +async fn import_repo_agents_md_overwrites_empty_targets() { let root = TempDir::new().expect("create tempdir"); let repo_root = root.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).expect("create git"); @@ -297,7 +321,9 @@ fn import_repo_agents_md_overwrites_empty_targets() { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: String::new(), cwd: Some(repo_root.clone()), + details: None, }]) + .await .expect("import"); assert_eq!( @@ -336,12 +362,13 @@ fn detect_repo_prefers_non_empty_dot_claude_agents_source() { repo_root.join("AGENTS.md").display(), ), cwd: Some(repo_root), + details: None, }] ); } -#[test] -fn import_repo_uses_non_empty_dot_claude_agents_source() { +#[tokio::test] +async fn import_repo_uses_non_empty_dot_claude_agents_source() { let root = TempDir::new().expect("create tempdir"); let repo_root = root.path().join("repo"); fs::create_dir_all(repo_root.join(".git")).expect("create git"); @@ -358,7 +385,9 @@ fn import_repo_uses_non_empty_dot_claude_agents_source() { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, description: String::new(), cwd: Some(repo_root.clone()), + details: None, }]) + .await .expect("import"); assert_eq!( @@ -378,6 +407,141 @@ fn migration_metric_tags_for_skills_include_skills_count() { ); } +#[test] +fn detect_home_lists_enabled_plugins_from_settings() { + let (_root, claude_home, codex_home) = fixture_paths(); + fs::create_dir_all(&claude_home).expect("create claude home"); + fs::write( + claude_home.join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true, + "deployer@acme-tools": true, + "analyzer@security-plugins": false + } + }"#, + ) + .expect("write settings"); + + let items = service_for_paths(claude_home.clone(), codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: true, + cwds: None, + }) + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: format!( + "Import enabled plugins from {}", + claude_home.join("settings.json").display() + ), + cwd: None, + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "acme-tools".to_string(), + plugin_names: vec!["deployer".to_string(), "formatter".to_string()], + }], + }), + }] + ); +} + +#[tokio::test] +async fn import_plugins_requires_details() { + let (_root, claude_home, codex_home) = fixture_paths(); + + let err = service_for_paths(claude_home, codex_home) + .import_plugins(/*cwd*/ None, /*details*/ None) + .await + .expect_err("expected missing details error"); + + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert_eq!(err.to_string(), "plugins migration item is missing details"); +} + +#[tokio::test] +async fn import_plugins_requires_source_marketplace_details() { + let (_root, claude_home, codex_home) = fixture_paths(); + fs::create_dir_all(&claude_home).expect("create claude home"); + fs::write( + claude_home.join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true + }, + "extraKnownMarketplaces": { + "acme-tools": { + "source": "github", + "repo": "acme-corp/claude-plugins" + } + } + }"#, + ) + .expect("write settings"); + + let outcome = service_for_paths(claude_home, codex_home) + .import_plugins( + /*cwd*/ None, + Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "other-tools".to_string(), + plugin_names: github_plugin_details().plugins[0].plugin_names.clone(), + }], + }), + ) + .await + .expect("import plugins"); + + assert_eq!( + outcome, + PluginImportOutcome { + succeeded_marketplaces: Vec::new(), + succeeded_plugin_ids: Vec::new(), + failed_marketplaces: vec!["other-tools".to_string()], + failed_plugin_ids: vec!["formatter@other-tools".to_string()], + } + ); +} + +#[tokio::test] +async fn import_plugins_defers_marketplace_source_validation_to_add_marketplace() { + let (_root, claude_home, codex_home) = fixture_paths(); + fs::create_dir_all(&claude_home).expect("create claude home"); + fs::write( + claude_home.join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true + }, + "extraKnownMarketplaces": { + "acme-tools": { + "source": "local", + "path": "./external_plugins/acme-tools" + } + } + }"#, + ) + .expect("write settings"); + + let outcome = service_for_paths(claude_home, codex_home) + .import_plugins(/*cwd*/ None, Some(github_plugin_details())) + .await + .expect("import plugins"); + + assert_eq!( + outcome, + PluginImportOutcome { + succeeded_marketplaces: Vec::new(), + succeeded_plugin_ids: Vec::new(), + failed_marketplaces: vec!["acme-tools".to_string()], + failed_plugin_ids: vec!["formatter@acme-tools".to_string()], + } + ); +} + #[test] fn import_skills_returns_only_new_skill_directory_count() { let (_root, claude_home, codex_home) = fixture_paths();