Codex Handbook
app-server/src/config/external_agent_config.rs 1930 lines
use codex_config::types::PluginConfig;use codex_core::config::Config;use codex_core::config::ConfigBuilder;use codex_core_plugins::PluginInstallRequest;use codex_core_plugins::PluginsManager;use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;use codex_core_plugins::marketplace::find_marketplace_manifest_path;use codex_core_plugins::marketplace_add::MarketplaceAddRequest;use codex_core_plugins::marketplace_add::add_marketplace;use codex_core_plugins::marketplace_add::is_local_marketplace_source;use codex_external_agent_migration::build_mcp_config_from_external;use codex_external_agent_migration::count_missing_commands;use codex_external_agent_migration::count_missing_subagents;use codex_external_agent_migration::hook_migration_event_names;use codex_external_agent_migration::import_commands;use codex_external_agent_migration::import_hooks;use codex_external_agent_migration::import_subagents;use codex_external_agent_migration::missing_command_names;use codex_external_agent_migration::missing_subagent_names;use codex_external_agent_sessions::ExternalAgentSessionMigration;use codex_external_agent_sessions::detect_recent_sessions;use codex_plugin::PluginId;use codex_protocol::protocol::Product;use serde_json::Value as JsonValue;use std::collections::BTreeMap;use std::collections::HashMap;use std::collections::HashSet;use std::ffi::OsString;use std::fs;use std::io;use std::path::Path;use std::path::PathBuf;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";const EXTERNAL_AGENT_DIR: &str = ".claude";const EXTERNAL_AGENT_CONFIG_MD: &str = "CLAUDE.md";const EXTERNAL_OFFICIAL_MARKETPLACE_NAME: &str = "claude-plugins-official";const EXTERNAL_OFFICIAL_MARKETPLACE_SOURCE: &str = "anthropics/claude-plugins-official";#[derive(Debug, Clone, PartialEq, Eq)]pub(crate) struct ExternalAgentConfigDetectOptions {    pub include_home: bool,    pub cwds: Option<Vec<PathBuf>>,}#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub(crate) enum ExternalAgentConfigMigrationItemType {    Config,    Skills,    AgentsMd,    Plugins,    McpServerConfig,    Subagents,    Hooks,    Commands,    Sessions,}#[derive(Debug, Clone, PartialEq, Eq)]pub(crate) struct PluginsMigration {    pub marketplace_name: String,    pub plugin_names: Vec<String>,}#[derive(Debug, Clone, PartialEq, Eq)]pub(crate) struct NamedMigration {    pub name: String,}#[derive(Debug, Clone, Default, PartialEq, Eq)]pub(crate) struct MigrationDetails {    pub plugins: Vec<PluginsMigration>,    pub sessions: Vec<ExternalAgentSessionMigration>,    pub mcp_servers: Vec<NamedMigration>,    pub hooks: Vec<NamedMigration>,    pub subagents: Vec<NamedMigration>,    pub commands: Vec<NamedMigration>,}#[derive(Debug, Clone, PartialEq, Eq)]pub(crate) struct PendingPluginImport {    pub cwd: Option<PathBuf>,    pub description: String,    pub details: MigrationDetails,}#[derive(Debug, Clone, Default, PartialEq, Eq)]pub(crate) struct PluginImportOutcome {    pub succeeded_marketplaces: Vec<String>,    pub succeeded_plugin_ids: Vec<String>,    pub failed_marketplaces: Vec<String>,    pub failed_plugin_ids: Vec<String>,    pub raw_errors: Vec<ExternalAgentConfigImportRawError>,}#[derive(Debug, Clone, Default, PartialEq, Eq)]pub(crate) struct ExternalAgentConfigImportOutcome {    pub pending_plugin_imports: Vec<PendingPluginImport>,    pub item_results: Vec<ExternalAgentConfigImportItemResult>,}#[derive(Debug, Clone, PartialEq, Eq)]pub(crate) struct ExternalAgentConfigImportItemResult {    pub item_type: ExternalAgentConfigMigrationItemType,    pub description: String,    pub cwd: Option<PathBuf>,    pub success_count: u32,    pub error_count: u32,    pub successes: Vec<ExternalAgentConfigImportSuccess>,    pub raw_errors: Vec<ExternalAgentConfigImportRawError>,}impl ExternalAgentConfigImportItemResult {    pub(crate) fn new(        item_type: ExternalAgentConfigMigrationItemType,        description: String,        cwd: Option<PathBuf>,    ) -> Self {        Self {            item_type,            description,            cwd,            success_count: 0,            error_count: 0,            successes: Vec::new(),            raw_errors: Vec::new(),        }    }    pub(crate) fn record_error(&mut self, raw_error: ExternalAgentConfigImportRawError) {        self.error_count = self.error_count.saturating_add(1);        self.raw_errors.push(raw_error);    }    pub(crate) fn record_success(&mut self, source: Option<String>, target: Option<String>) {        self.success_count = self.success_count.saturating_add(1);        self.successes.push(ExternalAgentConfigImportSuccess {            item_type: self.item_type,            cwd: self.cwd.clone(),            source,            target,        });    }}#[derive(Debug, Clone, PartialEq, Eq)]pub(crate) struct ExternalAgentConfigImportSuccess {    pub item_type: ExternalAgentConfigMigrationItemType,    pub cwd: Option<PathBuf>,    pub source: Option<String>,    pub target: Option<String>,}#[derive(Debug, Clone, PartialEq, Eq)]pub(crate) struct ExternalAgentConfigImportRawError {    pub item_type: ExternalAgentConfigMigrationItemType,    pub error_type: Option<String>,    pub failure_stage: String,    pub message: String,    pub cwd: Option<PathBuf>,    pub source: Option<String>,}#[derive(Debug, Clone, PartialEq, Eq)]pub(crate) struct ExternalAgentConfigMigrationItem {    pub item_type: ExternalAgentConfigMigrationItemType,    pub description: String,    pub cwd: Option<PathBuf>,    pub details: Option<MigrationDetails>,}#[derive(Clone)]pub(crate) struct ExternalAgentConfigService {    codex_home: PathBuf,    external_agent_home: PathBuf,}impl ExternalAgentConfigService {    pub(crate) fn new(codex_home: PathBuf) -> Self {        let external_agent_home = default_external_agent_home();        Self {            codex_home,            external_agent_home,        }    }    #[cfg(test)]    fn new_for_test(codex_home: PathBuf, external_agent_home: PathBuf) -> Self {        Self {            codex_home,            external_agent_home,        }    }    pub(crate) async fn detect(        &self,        params: ExternalAgentConfigDetectOptions,    ) -> io::Result<Vec<ExternalAgentConfigMigrationItem>> {        let mut items = Vec::new();        if params.include_home {            self.detect_migrations(/*repo_root*/ None, &mut items)                .await?;        }        for cwd in params.cwds.as_deref().unwrap_or(&[]) {            let Some(repo_root) = find_repo_root(Some(cwd))? else {                continue;            };            self.detect_migrations(Some(&repo_root), &mut items).await?;        }        Ok(items)    }    pub(crate) fn external_agent_session_source_path(        &self,        path: &Path,    ) -> io::Result<Option<PathBuf>> {        if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {            return Ok(None);        }        let path = match fs::canonicalize(path) {            Ok(path) => path,            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),            Err(err) => return Err(err),        };        let projects_root = match fs::canonicalize(self.external_agent_home.join("projects")) {            Ok(projects_root) => projects_root,            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),            Err(err) => return Err(err),        };        Ok(path.starts_with(projects_root).then_some(path))    }    pub(crate) async fn import(        &self,        migration_items: Vec<ExternalAgentConfigMigrationItem>,    ) -> io::Result<ExternalAgentConfigImportOutcome> {        let mut outcome = ExternalAgentConfigImportOutcome::default();        for migration_item in migration_items {            let item_type = migration_item.item_type;            let description = migration_item.description.clone();            let cwd_for_log = migration_item.cwd.clone();            let mut item_result = ExternalAgentConfigImportItemResult::new(                item_type,                description.clone(),                cwd_for_log.clone(),            );            let import_result = match migration_item.item_type {                ExternalAgentConfigMigrationItemType::Config => (|| {                    if let Some((source, target)) =                        self.import_config(migration_item.cwd.as_deref())?                    {                        item_result.record_success(Some(source), Some(target));                    }                    emit_migration_metric(                        EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,                        ExternalAgentConfigMigrationItemType::Config,                        /*skills_count*/ None,                    );                    Ok(())                })(),                ExternalAgentConfigMigrationItemType::Skills => (|| {                    let imported_skills = self.import_skills(migration_item.cwd.as_deref())?;                    emit_migration_metric(                        EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,                        ExternalAgentConfigMigrationItemType::Skills,                        Some(imported_skills.len()),                    );                    for skill_name in imported_skills {                        item_result.record_success(Some(skill_name.clone()), Some(skill_name));                    }                    Ok(())                })(),                ExternalAgentConfigMigrationItemType::AgentsMd => (|| {                    if let Some((source, target)) =                        self.import_agents_md(migration_item.cwd.as_deref())?                    {                        item_result.record_success(Some(source), Some(target));                    }                    emit_migration_metric(                        EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,                        ExternalAgentConfigMigrationItemType::AgentsMd,                        /*skills_count*/ None,                    );                    Ok(())                })(),                ExternalAgentConfigMigrationItemType::Plugins => {                    async {                        let cwd = migration_item.cwd;                        let details = match migration_item.details {                            Some(details) => details,                            None => {                                let err = invalid_data_error(                                    "plugins migration item is missing details".to_string(),                                );                                record_import_error(                                    &mut item_result,                                    "plugin_import",                                    err.to_string(),                                    /*source*/ None,                                );                                return Err(err);                            }                        };                        let (local_details, remote_details) = match self                            .partition_plugin_migration_details(cwd.as_deref(), details)                        {                            Ok(details) => details,                            Err(err) => {                                record_import_error(                                    &mut item_result,                                    "plugin_import",                                    err.to_string(),                                    /*source*/ None,                                );                                return Err(err);                            }                        };                        if let Some(local_details) = local_details {                            let plugin_outcome = match self                                .import_plugins(cwd.as_deref(), Some(local_details))                                .await                            {                                Ok(plugin_outcome) => plugin_outcome,                                Err(err) => {                                    record_import_error(                                        &mut item_result,                                        "plugin_import",                                        err.to_string(),                                        /*source*/ None,                                    );                                    return Err(err);                                }                            };                            for plugin_id in plugin_outcome.succeeded_plugin_ids {                                item_result                                    .record_success(Some(plugin_id.clone()), Some(plugin_id));                            }                            for raw_error in plugin_outcome.raw_errors {                                item_result.record_error(raw_error);                            }                        }                        if let Some(remote_details) = remote_details {                            outcome.pending_plugin_imports.push(PendingPluginImport {                                cwd,                                description: description.clone(),                                details: remote_details,                            });                        }                        emit_migration_metric(                            EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,                            ExternalAgentConfigMigrationItemType::Plugins,                            /*skills_count*/ None,                        );                        Ok(())                    }                    .await                }                ExternalAgentConfigMigrationItemType::McpServerConfig => (|| {                    let migrated_server_names =                        self.import_mcp_server_config(migration_item.cwd.as_deref())?;                    emit_migration_metric(                        EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,                        ExternalAgentConfigMigrationItemType::McpServerConfig,                        /*skills_count*/ None,                    );                    for server_name in migrated_server_names {                        item_result.record_success(Some(server_name.clone()), Some(server_name));                    }                    Ok(())                })(),                ExternalAgentConfigMigrationItemType::Subagents => (|| {                    let imported_subagents =                        self.import_subagents(migration_item.cwd.as_deref())?;                    emit_migration_metric(                        EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,                        ExternalAgentConfigMigrationItemType::Subagents,                        Some(imported_subagents.len()),                    );                    for subagent_name in imported_subagents {                        item_result                            .record_success(Some(subagent_name.clone()), Some(subagent_name));                    }                    Ok(())                })(),                ExternalAgentConfigMigrationItemType::Hooks => (|| {                    let migrated_hook_names = self.import_hooks(migration_item.cwd.as_deref())?;                    emit_migration_metric(                        EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,                        ExternalAgentConfigMigrationItemType::Hooks,                        /*skills_count*/ None,                    );                    for hook_name in migrated_hook_names {                        item_result.record_success(Some(hook_name.clone()), Some(hook_name));                    }                    Ok(())                })(),                ExternalAgentConfigMigrationItemType::Commands => (|| {                    let imported_commands = self.import_commands(migration_item.cwd.as_deref())?;                    emit_migration_metric(                        EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,                        ExternalAgentConfigMigrationItemType::Commands,                        Some(imported_commands.len()),                    );                    for command_name in imported_commands {                        item_result.record_success(Some(command_name.clone()), Some(command_name));                    }                    Ok(())                })(),                ExternalAgentConfigMigrationItemType::Sessions => Ok(()),            };            if let Err(err) = import_result {                if item_type == ExternalAgentConfigMigrationItemType::Plugins {                    outcome.item_results.push(item_result);                    continue;                }                return Err(err);            }            outcome.item_results.push(item_result);        }        Ok(outcome)    }    async fn detect_migrations(        &self,        repo_root: Option<&Path>,        items: &mut Vec<ExternalAgentConfigMigrationItem>,    ) -> io::Result<()> {        let cwd = repo_root.map(Path::to_path_buf);        let source_settings = repo_root.map_or_else(            || self.external_agent_home.join("settings.json"),            |repo_root| repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),        );        let settings = effective_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 let Some(settings) = settings.as_ref() {            let migrated = build_config_from_external(settings)?;            if !is_empty_toml_table(&migrated) {                let mut should_include = true;                if target_config.exists() {                    let existing_raw = fs::read_to_string(&target_config)?;                    let mut existing = if existing_raw.trim().is_empty() {                        TomlValue::Table(Default::default())                    } else {                        toml::from_str::<TomlValue>(&existing_raw).map_err(|err| {                            invalid_data_error(format!("invalid existing config.toml: {err}"))                        })?                    };                    should_include = merge_missing_toml_values(&mut existing, &migrated)?;                }                if should_include {                    items.push(ExternalAgentConfigMigrationItem {                        item_type: ExternalAgentConfigMigrationItemType::Config,                        description: format!(                            "Migrate {} into {}",                            source_settings.display(),                            target_config.display()                        ),                        cwd: cwd.clone(),                        details: None,                    });                    emit_migration_metric(                        EXTERNAL_AGENT_CONFIG_DETECT_METRIC,                        ExternalAgentConfigMigrationItemType::Config,                        /*skills_count*/ None,                    );                }            }        }        let source_root = self.source_root(repo_root);        let mcp_settings = self.mcp_settings(repo_root, settings.clone())?;        let migrated_mcp = build_mcp_config_from_external(            source_root.as_path(),            Some(self.external_agent_home.as_path()),            mcp_settings.as_ref(),        )?;        let mut mcp_server_names = migrated_mcp_server_names(&migrated_mcp);        if !is_empty_toml_table(&migrated_mcp) {            if target_config.exists() {                let existing_raw = fs::read_to_string(&target_config)?;                let mut existing = if existing_raw.trim().is_empty() {                    TomlValue::Table(Default::default())                } else {                    toml::from_str::<TomlValue>(&existing_raw).map_err(|err| {                        invalid_data_error(format!("invalid existing config.toml: {err}"))                    })?                };                mcp_server_names = merge_missing_mcp_servers(&mut existing, &migrated_mcp)?;            }            if !mcp_server_names.is_empty() {                items.push(ExternalAgentConfigMigrationItem {                    item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,                    description: format!(                        "Migrate MCP servers from {} into {}",                        source_root.display(),                        target_config.display()                    ),                    cwd: cwd.clone(),                    details: Some(MigrationDetails {                        mcp_servers: named_migrations(mcp_server_names),                        ..Default::default()                    }),                });                emit_migration_metric(                    EXTERNAL_AGENT_CONFIG_DETECT_METRIC,                    ExternalAgentConfigMigrationItemType::McpServerConfig,                    /*skills_count*/ None,                );            }        }        let source_external_agent_dir = repo_root.map_or_else(            || self.external_agent_home.clone(),            |repo_root| repo_root.join(EXTERNAL_AGENT_DIR),        );        let target_hooks = repo_root.map_or_else(            || self.codex_home.join("hooks.json"),            |repo_root| repo_root.join(".codex").join("hooks.json"),        );        let hook_event_names =            hook_migration_event_names(source_external_agent_dir.as_path(), &target_hooks)?;        if !hook_event_names.is_empty() && is_missing_or_empty_text_file(&target_hooks)? {            items.push(ExternalAgentConfigMigrationItem {                item_type: ExternalAgentConfigMigrationItemType::Hooks,                description: format!(                    "Migrate hooks from {} to {}",                    source_external_agent_dir.display(),                    target_hooks.display()                ),                cwd: cwd.clone(),                details: Some(MigrationDetails {                    hooks: named_migrations(hook_event_names),                    ..Default::default()                }),            });            emit_migration_metric(                EXTERNAL_AGENT_CONFIG_DETECT_METRIC,                ExternalAgentConfigMigrationItemType::Hooks,                /*skills_count*/ None,            );        }        let source_skills = repo_root.map_or_else(            || self.external_agent_home.join("skills"),            |repo_root| repo_root.join(EXTERNAL_AGENT_DIR).join("skills"),        );        let target_skills = repo_root.map_or_else(            || self.home_target_skills_dir(),            |repo_root| repo_root.join(".agents").join("skills"),        );        let skills_count = count_missing_subdirectories(&source_skills, &target_skills)?;        if skills_count > 0 {            items.push(ExternalAgentConfigMigrationItem {                item_type: ExternalAgentConfigMigrationItemType::Skills,                description: format!(                    "Migrate skills from {} to {}",                    source_skills.display(),                    target_skills.display()                ),                cwd: cwd.clone(),                details: None,            });            emit_migration_metric(                EXTERNAL_AGENT_CONFIG_DETECT_METRIC,                ExternalAgentConfigMigrationItemType::Skills,                Some(skills_count),            );        }        let source_commands = source_external_agent_dir.join("commands");        let target_command_skills = repo_root.map_or_else(            || self.home_target_skills_dir(),            |repo_root| repo_root.join(".agents").join("skills"),        );        let commands_count = count_missing_commands(&source_commands, &target_command_skills)?;        if commands_count > 0 {            let command_names = missing_command_names(&source_commands, &target_command_skills)?;            items.push(ExternalAgentConfigMigrationItem {                item_type: ExternalAgentConfigMigrationItemType::Commands,                description: format!(                    "Migrate commands from {} to {}",                    source_commands.display(),                    target_command_skills.display()                ),                cwd: cwd.clone(),                details: Some(MigrationDetails {                    commands: named_migrations(command_names),                    ..Default::default()                }),            });            emit_migration_metric(                EXTERNAL_AGENT_CONFIG_DETECT_METRIC,                ExternalAgentConfigMigrationItemType::Commands,                Some(commands_count),            );        }        let source_subagents = source_external_agent_dir.join("agents");        let target_subagents = repo_root.map_or_else(            || self.codex_home.join("agents"),            |repo_root| repo_root.join(".codex").join("agents"),        );        let subagents_count = count_missing_subagents(&source_subagents, &target_subagents)?;        if subagents_count > 0 {            let subagent_names = missing_subagent_names(&source_subagents, &target_subagents)?;            items.push(ExternalAgentConfigMigrationItem {                item_type: ExternalAgentConfigMigrationItemType::Subagents,                description: format!(                    "Migrate subagents from {} to {}",                    source_subagents.display(),                    target_subagents.display()                ),                cwd: cwd.clone(),                details: Some(MigrationDetails {                    subagents: named_migrations(subagent_names),                    ..Default::default()                }),            });            emit_migration_metric(                EXTERNAL_AGENT_CONFIG_DETECT_METRIC,                ExternalAgentConfigMigrationItemType::Subagents,                Some(subagents_count),            );        }        let source_agents_md = if let Some(repo_root) = repo_root {            find_repo_agents_md_source(repo_root)?        } else {            let path = self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD);            is_non_empty_text_file(&path)?.then_some(path)        };        let target_agents_md = repo_root.map_or_else(            || self.codex_home.join("AGENTS.md"),            |repo_root| repo_root.join("AGENTS.md"),        );        if let Some(source_agents_md) = source_agents_md            && is_missing_or_empty_text_file(&target_agents_md)?        {            items.push(ExternalAgentConfigMigrationItem {                item_type: ExternalAgentConfigMigrationItemType::AgentsMd,                description: format!(                    "Migrate {} to {}",                    source_agents_md.display(),                    target_agents_md.display()                ),                cwd: cwd.clone(),                details: None,            });            emit_migration_metric(                EXTERNAL_AGENT_CONFIG_DETECT_METRIC,                ExternalAgentConfigMigrationItemType::AgentsMd,                /*skills_count*/ None,            );        }        if let Some(settings) = settings.as_ref() {            match ConfigBuilder::default()                .codex_home(self.codex_home.clone())                .fallback_cwd(Some(self.codex_home.clone()))                .build()                .await            {                Ok(config) => {                    let configured_plugin_ids = config                        .config_layer_stack                        .get_active_user_layer()                        .and_then(|user_layer| user_layer.config.get("plugins"))                        .and_then(|plugins| {                            match plugins.clone().try_into::<HashMap<String, PluginConfig>>() {                                Ok(plugins) => Some(plugins),                                Err(err) => {                                    tracing::warn!("invalid plugins config: {err}");                                    None                                }                            }                        })                        .map(|plugins| plugins.into_keys().collect::<HashSet<_>>())                        .unwrap_or_default();                    let configured_marketplace_plugins = configured_marketplace_plugins(                        &config,                        &PluginsManager::new(self.codex_home.clone()),                    )?;                    if let Some(item) = self.detect_plugin_migration(                        source_settings.as_path(),                        repo_root.unwrap_or(self.external_agent_home.as_path()),                        cwd.clone(),                        settings,                        &configured_plugin_ids,                        &configured_marketplace_plugins,                    ) {                        items.push(item);                    }                }                Err(err) => {                    tracing::warn!(                        error = %err,                        settings_path = %source_settings.display(),                        "skipping external agent plugin migration detection because config load failed"                    );                }            }        }        if repo_root.is_none() {            let sessions = detect_recent_sessions(&self.external_agent_home, &self.codex_home)?;            if !sessions.is_empty() {                items.push(ExternalAgentConfigMigrationItem {                    item_type: ExternalAgentConfigMigrationItemType::Sessions,                    description: format!(                        "Migrate recent sessions from {}",                        self.external_agent_home.join("projects").display()                    ),                    cwd: None,                    details: Some(MigrationDetails {                        sessions,                        ..Default::default()                    }),                });                emit_migration_metric(                    EXTERNAL_AGENT_CONFIG_DETECT_METRIC,                    ExternalAgentConfigMigrationItemType::Sessions,                    /*skills_count*/ None,                );            }        }        Ok(())    }    fn home_target_skills_dir(&self) -> PathBuf {        self.codex_home            .parent()            .map(|parent| parent.join(".agents").join("skills"))            .unwrap_or_else(|| PathBuf::from(".agents").join("skills"))    }    fn mcp_settings(        &self,        repo_root: Option<&Path>,        source_settings: Option<JsonValue>,    ) -> io::Result<Option<JsonValue>> {        if repo_root.is_some() && source_settings.is_none() {            let home_settings = self.external_agent_home.join("settings.json");            match effective_external_settings(&home_settings) {                Ok(settings) => Ok(settings),                Err(err) => {                    tracing::warn!(                        path = %home_settings.display(),                        error = %err,                        "ignoring invalid external agent home settings during repo MCP migration"                    );                    Ok(None)                }            }        } else {            Ok(source_settings)        }    }    fn source_root(&self, repo_root: Option<&Path>) -> PathBuf {        repo_root.map_or_else(            || {                self.external_agent_home                    .parent()                    .map(Path::to_path_buf)                    .unwrap_or_else(|| PathBuf::from("."))            },            Path::to_path_buf,        )    }    fn detect_plugin_migration(        &self,        source_settings: &Path,        source_root: &Path,        cwd: Option<PathBuf>,        settings: &JsonValue,        configured_plugin_ids: &HashSet<String>,        configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,    ) -> Option<ExternalAgentConfigMigrationItem> {        let plugin_details = extract_plugin_migration_details(            settings,            source_root,            configured_plugin_ids,            configured_marketplace_plugins,        )?;        emit_migration_metric(            EXTERNAL_AGENT_CONFIG_DETECT_METRIC,            ExternalAgentConfigMigrationItemType::Plugins,            /*skills_count*/ None,        );        Some(ExternalAgentConfigMigrationItem {            item_type: ExternalAgentConfigMigrationItemType::Plugins,            description: format!("Migrate enabled plugins from {}", source_settings.display()),            cwd,            details: Some(plugin_details),        })    }    fn partition_plugin_migration_details(        &self,        cwd: Option<&Path>,        details: MigrationDetails,    ) -> io::Result<(Option<MigrationDetails>, Option<MigrationDetails>)> {        let source_settings = cwd.map_or_else(            || self.external_agent_home.join("settings.json"),            |cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"),        );        let source_root = cwd.unwrap_or(self.external_agent_home.as_path());        let import_sources = effective_external_settings(&source_settings)?            .map(|settings| collect_marketplace_import_sources(&settings, source_root))            .unwrap_or_default();        let mut local_plugins = Vec::new();        let mut remote_plugins = Vec::new();        for plugin_group in details.plugins {            let is_local = import_sources                .get(&plugin_group.marketplace_name)                .and_then(|import_source| {                    is_local_marketplace_source(                        &import_source.source,                        import_source.ref_name.clone(),                    )                    .ok()                })                .unwrap_or(false);            if is_local {                local_plugins.push(plugin_group);            } else {                remote_plugins.push(plugin_group);            }        }        let local_details = (!local_plugins.is_empty()).then_some(MigrationDetails {            plugins: local_plugins,            ..Default::default()        });        let remote_details = (!remote_plugins.is_empty()).then_some(MigrationDetails {            plugins: remote_plugins,            ..Default::default()        });        Ok((local_details, remote_details))    }    pub(crate) async fn import_plugins(        &self,        cwd: Option<&Path>,        details: Option<MigrationDetails>,    ) -> io::Result<PluginImportOutcome> {        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::<Vec<_>>();            let source_settings = cwd.map_or_else(                || self.external_agent_home.join("settings.json"),                |cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"),            );            let source_root = cwd.unwrap_or(self.external_agent_home.as_path());            let import_source =                effective_external_settings(&source_settings)?.and_then(|settings| {                    collect_marketplace_import_sources(&settings, source_root)                        .remove(&marketplace_name)                });            let Some(import_source) = import_source else {                let message = format!(                    "external agent plugin marketplace source was not found: {marketplace_name}"                );                record_plugin_import_errors(                    &mut outcome,                    cwd,                    &plugin_ids,                    "plugin_import",                    message,                );                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) => {                    let Some(marketplace_path) = find_marketplace_manifest_path(                        add_marketplace_outcome.installed_root.as_path(),                    ) else {                        let message = format!(                            "plugin marketplace manifest was not found after install: {marketplace_name}"                        );                        record_plugin_import_errors(                            &mut outcome,                            cwd,                            &plugin_ids,                            "plugin_import",                            message,                        );                        outcome.failed_marketplaces.push(marketplace_name);                        outcome.failed_plugin_ids.extend(plugin_ids);                        continue;                    };                    outcome                        .succeeded_marketplaces                        .push(marketplace_name.clone());                    marketplace_path                }                Err(err) => {                    record_plugin_import_errors(                        &mut outcome,                        cwd,                        &plugin_ids,                        "plugin_import",                        err.to_string(),                    );                    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(err) => {                        let plugin_id = format!("{plugin_name}@{marketplace_name}");                        outcome.failed_plugin_ids.push(plugin_id.clone());                        outcome.raw_errors.push(plugin_import_raw_error(                            cwd,                            "plugin_import",                            err.to_string(),                            Some(plugin_id),                        ));                    }                }            }        }        Ok(outcome)    }    fn import_config(&self, cwd: Option<&Path>) -> io::Result<Option<(String, String)>> {        let repo_root = find_repo_root(cwd)?;        let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() {            (                repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),                repo_root.join(".codex").join("config.toml"),            )        } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {            return Ok(None);        } else {            (                self.external_agent_home.join("settings.json"),                self.codex_home.join("config.toml"),            )        };        let Some(settings) = effective_external_settings(&source_settings)? else {            return Ok(None);        };        let migrated = build_config_from_external(&settings)?;        if is_empty_toml_table(&migrated) {            return Ok(None);        }        let Some(target_parent) = target_config.parent() else {            return Err(invalid_data_error("config target path has no parent"));        };        fs::create_dir_all(target_parent)?;        if !target_config.exists() {            write_toml_file(&target_config, &migrated)?;            return Ok(Some((                source_settings.display().to_string(),                target_config.display().to_string(),            )));        }        let existing_raw = fs::read_to_string(&target_config)?;        let mut existing = if existing_raw.trim().is_empty() {            TomlValue::Table(Default::default())        } else {            toml::from_str::<TomlValue>(&existing_raw)                .map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))?        };        let changed = merge_missing_toml_values(&mut existing, &migrated)?;        if !changed {            return Ok(None);        }        write_toml_file(&target_config, &existing)?;        Ok(Some((            source_settings.display().to_string(),            target_config.display().to_string(),        )))    }    fn import_mcp_server_config(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {        let repo_root = find_repo_root(cwd)?;        let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() {            (                repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),                repo_root.join(".codex").join("config.toml"),            )        } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {            return Ok(Vec::new());        } else {            (                self.external_agent_home.join("settings.json"),                self.codex_home.join("config.toml"),            )        };        let settings = self.mcp_settings(            repo_root.as_deref(),            effective_external_settings(&source_settings)?,        )?;        let migrated = build_mcp_config_from_external(            self.source_root(repo_root.as_deref()).as_path(),            Some(self.external_agent_home.as_path()),            settings.as_ref(),        )?;        if is_empty_toml_table(&migrated) {            return Ok(Vec::new());        }        let Some(target_parent) = target_config.parent() else {            return Err(invalid_data_error("config target path has no parent"));        };        fs::create_dir_all(target_parent)?;        if !target_config.exists() {            let migrated_server_names = migrated_mcp_server_names(&migrated);            write_toml_file(&target_config, &migrated)?;            return Ok(migrated_server_names);        }        let existing_raw = fs::read_to_string(&target_config)?;        let mut existing = if existing_raw.trim().is_empty() {            TomlValue::Table(Default::default())        } else {            toml::from_str::<TomlValue>(&existing_raw)                .map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))?        };        let merged_server_names = merge_missing_mcp_servers(&mut existing, &migrated)?;        if !merged_server_names.is_empty() {            write_toml_file(&target_config, &existing)?;        }        Ok(merged_server_names)    }    fn import_subagents(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {        let (source_agents, target_agents) = if let Some(repo_root) = find_repo_root(cwd)? {            (                repo_root.join(EXTERNAL_AGENT_DIR).join("agents"),                repo_root.join(".codex").join("agents"),            )        } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {            return Ok(Vec::new());        } else {            (                self.external_agent_home.join("agents"),                self.codex_home.join("agents"),            )        };        import_subagents(&source_agents, &target_agents)    }    fn import_hooks(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {        let (source_external_agent_dir, target_hooks) =            if let Some(repo_root) = find_repo_root(cwd)? {                (                    repo_root.join(EXTERNAL_AGENT_DIR),                    repo_root.join(".codex").join("hooks.json"),                )            } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {                return Ok(Vec::new());            } else {                (                    self.external_agent_home.clone(),                    self.codex_home.join("hooks.json"),                )            };        let hook_names = hook_migration_event_names(&source_external_agent_dir, &target_hooks)?;        if import_hooks(&source_external_agent_dir, &target_hooks)? {            Ok(hook_names)        } else {            Ok(Vec::new())        }    }    fn import_commands(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {        let (source_commands, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? {            (                repo_root.join(EXTERNAL_AGENT_DIR).join("commands"),                repo_root.join(".agents").join("skills"),            )        } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {            return Ok(Vec::new());        } else {            (                self.external_agent_home.join("commands"),                self.home_target_skills_dir(),            )        };        import_commands(&source_commands, &target_skills)    }    fn import_skills(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {        let (source_skills, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? {            (                repo_root.join(EXTERNAL_AGENT_DIR).join("skills"),                repo_root.join(".agents").join("skills"),            )        } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {            return Ok(Vec::new());        } else {            (                self.external_agent_home.join("skills"),                self.home_target_skills_dir(),            )        };        if !source_skills.is_dir() {            return Ok(Vec::new());        }        fs::create_dir_all(&target_skills)?;        let mut copied_names = Vec::new();        for entry in fs::read_dir(&source_skills)? {            let entry = entry?;            let file_type = entry.file_type()?;            if !file_type.is_dir() {                continue;            }            let target = target_skills.join(entry.file_name());            if target.exists() {                continue;            }            copy_dir_recursive(&entry.path(), &target)?;            copied_names.push(entry.file_name().to_string_lossy().to_string());        }        Ok(copied_names)    }    fn import_agents_md(&self, cwd: Option<&Path>) -> io::Result<Option<(String, String)>> {        let (source_agents_md, target_agents_md) = if let Some(repo_root) = find_repo_root(cwd)? {            let Some(source_agents_md) = find_repo_agents_md_source(&repo_root)? else {                return Ok(None);            };            (source_agents_md, repo_root.join("AGENTS.md"))        } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {            return Ok(None);        } else {            (                self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD),                self.codex_home.join("AGENTS.md"),            )        };        if !is_non_empty_text_file(&source_agents_md)?            || !is_missing_or_empty_text_file(&target_agents_md)?        {            return Ok(None);        }        let Some(target_parent) = target_agents_md.parent() else {            return Err(invalid_data_error("AGENTS.md target path has no parent"));        };        fs::create_dir_all(target_parent)?;        rewrite_and_copy_text_file(&source_agents_md, &target_agents_md)?;        Ok(Some((            source_agents_md.display().to_string(),            target_agents_md.display().to_string(),        )))    }}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(EXTERNAL_AGENT_DIR);    }    PathBuf::from(EXTERNAL_AGENT_DIR)}fn read_external_settings(path: &Path) -> io::Result<Option<JsonValue>> {    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 effective_external_settings(project_settings: &Path) -> io::Result<Option<JsonValue>> {    let mut effective = read_external_settings(project_settings)?;    let Some(settings_dir) = project_settings.parent() else {        return Ok(effective);    };    let local_settings = settings_dir.join("settings.local.json");    let local_settings = match read_external_settings(&local_settings) {        Ok(Some(local_settings)) => local_settings,        Ok(None) => return Ok(effective),        Err(err) if err.kind() == io::ErrorKind::InvalidData => return Ok(effective),        Err(err) => return Err(err),    };    if let Some(effective) = effective.as_mut() {        merge_json_settings(effective, &local_settings);    } else {        effective = Some(local_settings);    }    Ok(effective)}fn merge_json_settings(existing: &mut JsonValue, incoming: &JsonValue) {    match (existing, incoming) {        (JsonValue::Object(existing), JsonValue::Object(incoming)) => {            for (key, incoming_value) in incoming {                match existing.get_mut(key) {                    Some(existing_value) => merge_json_settings(existing_value, incoming_value),                    None => {                        existing.insert(key.clone(), incoming_value.clone());                    }                }            }        }        (existing, incoming) => {            *existing = incoming.clone();        }    }}fn extract_plugin_migration_details(    settings: &JsonValue,    source_root: &Path,    configured_plugin_ids: &HashSet<String>,    configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,) -> Option<MigrationDetails> {    let loadable_marketplaces = collect_marketplace_import_sources(settings, source_root)        .into_iter()        .filter_map(|(marketplace_name, source)| {            is_local_marketplace_source(&source.source, source.ref_name)                .ok()                .map(|_| marketplace_name)        })        .collect::<HashSet<_>>();    let mut plugins = BTreeMap::new();    for plugin_id in collect_enabled_plugins(settings)        .into_iter()        .filter(|plugin_id| !configured_plugin_ids.contains(plugin_id))    {        let Ok(plugin_id) = PluginId::parse(&plugin_id) else {            continue;        };        if let Some(installable_plugins) =            configured_marketplace_plugins.get(&plugin_id.marketplace_name)        {            if !installable_plugins.contains(&plugin_id.plugin_name) {                continue;            }        } else if !loadable_marketplaces.contains(&plugin_id.marketplace_name) {            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::<Vec<_>>();    if plugins.is_empty() {        return None;    }    Some(MigrationDetails {        plugins,        ..Default::default()    })}fn collect_enabled_plugins(settings: &JsonValue) -> Vec<String> {    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 has_enabled_plugin_for_marketplace(settings: &JsonValue, marketplace_name: &str) -> bool {    collect_enabled_plugins(settings)        .into_iter()        .any(|plugin_id| {            PluginId::parse(&plugin_id)                .map(|plugin_id| plugin_id.marketplace_name == marketplace_name)                .unwrap_or(false)        })}fn configured_marketplace_plugins(    config: &Config,    plugins_manager: &PluginsManager,) -> io::Result<BTreeMap<String, HashSet<String>>> {    let plugins_input = config.plugins_config_input();    let marketplaces = plugins_manager        .list_marketplaces_for_config(&plugins_input, &[], /*include_openai_curated*/ true)        .map_err(|err| {            invalid_data_error(format!("failed to list configured marketplaces: {err}"))        })?;    let mut marketplace_plugins = BTreeMap::new();    for marketplace in marketplaces.marketplaces {        let plugins = marketplace            .plugins            .into_iter()            .filter(|plugin| {                plugin.policy.installation != MarketplacePluginInstallPolicy::NotAvailable            })            .filter(|plugin| {                plugin                    .policy                    .products                    .as_deref()                    .is_none_or(|products| Product::Codex.matches_product_restriction(products))            })            .map(|plugin| plugin.name)            .collect::<HashSet<_>>();        marketplace_plugins.insert(marketplace.name, plugins);    }    Ok(marketplace_plugins)}fn collect_marketplace_import_sources(    settings: &JsonValue,    source_root: &Path,) -> BTreeMap<String, MarketplaceImportSource> {    let mut import_sources: BTreeMap<String, MarketplaceImportSource> = settings        .as_object()        .and_then(|settings| settings.get("extraKnownMarketplaces"))        .and_then(JsonValue::as_object)        .map(|extra_known_marketplaces| {            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 source = resolve_external_marketplace_source(&source, source_root);                    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()        })        .unwrap_or_default();    if has_enabled_plugin_for_marketplace(settings, EXTERNAL_OFFICIAL_MARKETPLACE_NAME)        && !import_sources.contains_key(EXTERNAL_OFFICIAL_MARKETPLACE_NAME)    {        import_sources.insert(            EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string(),            MarketplaceImportSource {                source: EXTERNAL_OFFICIAL_MARKETPLACE_SOURCE.to_string(),                ref_name: None,            },        );    }    import_sources}#[derive(Debug, Clone, PartialEq, Eq)]struct MarketplaceImportSource {    source: String,    ref_name: Option<String>,}fn resolve_external_marketplace_source(source: &str, source_root: &Path) -> String {    if !looks_like_relative_local_path(source) {        return source.to_string();    }    source_root.join(source).display().to_string()}fn looks_like_relative_local_path(source: &str) -> bool {    source.starts_with("./") || source.starts_with("../") || source == "." || source == ".."}fn find_repo_root(cwd: Option<&Path>) -> io::Result<Option<PathBuf>> {    let Some(cwd) = cwd.filter(|cwd| !cwd.as_os_str().is_empty()) else {        return Ok(None);    };    let mut current = if cwd.is_absolute() {        cwd.to_path_buf()    } else {        std::env::current_dir()?.join(cwd)    };    if !current.exists() {        return Ok(None);    }    if current.is_file() {        let Some(parent) = current.parent() else {            return Ok(None);        };        current = parent.to_path_buf();    }    let fallback = current.clone();    loop {        let git_path = current.join(".git");        if git_path.is_dir() || git_path.is_file() {            return Ok(Some(current));        }        if !current.pop() {            break;        }    }    Ok(Some(fallback))}fn collect_subdirectory_names(path: &Path) -> io::Result<HashSet<OsString>> {    let mut names = HashSet::new();    if !path.is_dir() {        return Ok(names);    }    for entry in fs::read_dir(path)? {        let entry = entry?;        if entry.file_type()?.is_dir() {            names.insert(entry.file_name());        }    }    Ok(names)}fn count_missing_subdirectories(source: &Path, target: &Path) -> io::Result<usize> {    let source_names = collect_subdirectory_names(source)?;    let target_names = collect_subdirectory_names(target)?;    Ok(source_names        .iter()        .filter(|name| !target_names.contains(*name))        .count())}fn is_missing_or_empty_text_file(path: &Path) -> io::Result<bool> {    if !path.exists() {        return Ok(true);    }    if !path.is_file() {        return Ok(false);    }    Ok(fs::read_to_string(path)?.trim().is_empty())}fn is_non_empty_text_file(path: &Path) -> io::Result<bool> {    if !path.is_file() {        return Ok(false);    }    Ok(!fs::read_to_string(path)?.trim().is_empty())}fn find_repo_agents_md_source(repo_root: &Path) -> io::Result<Option<PathBuf>> {    for candidate in [        repo_root.join(EXTERNAL_AGENT_CONFIG_MD),        repo_root            .join(EXTERNAL_AGENT_DIR)            .join(EXTERNAL_AGENT_CONFIG_MD),    ] {        if is_non_empty_text_file(&candidate)? {            return Ok(Some(candidate));        }    }    Ok(None)}fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> {    fs::create_dir_all(target)?;    for entry in fs::read_dir(source)? {        let entry = entry?;        let source_path = entry.path();        let target_path = target.join(entry.file_name());        let file_type = entry.file_type()?;        if file_type.is_dir() {            copy_dir_recursive(&source_path, &target_path)?;            continue;        }        if file_type.is_file() {            if is_skill_md(&source_path) {                rewrite_and_copy_text_file(&source_path, &target_path)?;            } else {                fs::copy(source_path, target_path)?;            }        }    }    Ok(())}fn is_skill_md(path: &Path) -> bool {    path.file_name()        .and_then(|name| name.to_str())        .is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"))}fn rewrite_and_copy_text_file(source: &Path, target: &Path) -> io::Result<()> {    let source_contents = fs::read_to_string(source)?;    let rewritten = rewrite_external_agent_terms(&source_contents);    fs::write(target, rewritten)}fn rewrite_external_agent_terms(content: &str) -> String {    let mut rewritten = replace_case_insensitive_with_boundaries(        content,        &EXTERNAL_AGENT_CONFIG_MD.to_ascii_lowercase(),        "AGENTS.md",    );    for from in [        "claude code",        "claude-code",        "claude_code",        "claudecode",        "claude",    ] {        rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex");    }    rewritten}fn replace_case_insensitive_with_boundaries(    input: &str,    needle: &str,    replacement: &str,) -> String {    let needle_lower = needle.to_ascii_lowercase();    if needle_lower.is_empty() {        return input.to_string();    }    let haystack_lower = input.to_ascii_lowercase();    let bytes = input.as_bytes();    let mut output = String::with_capacity(input.len());    let mut last_emitted = 0usize;    let mut search_start = 0usize;    while let Some(relative_pos) = haystack_lower[search_start..].find(&needle_lower) {        let start = search_start + relative_pos;        let end = start + needle_lower.len();        let boundary_before = start == 0 || !is_word_byte(bytes[start - 1]);        let boundary_after = end == bytes.len() || !is_word_byte(bytes[end]);        if boundary_before && boundary_after {            output.push_str(&input[last_emitted..start]);            output.push_str(replacement);            last_emitted = end;        }        search_start = start + 1;    }    if last_emitted == 0 {        return input.to_string();    }    output.push_str(&input[last_emitted..]);    output}fn is_word_byte(byte: u8) -> bool {    byte.is_ascii_alphanumeric() || byte == b'_'}fn build_config_from_external(settings: &JsonValue) -> io::Result<TomlValue> {    let Some(settings_obj) = settings.as_object() else {        return Err(invalid_data_error(            "external agent settings root must be an object",        ));    };    let mut root = toml::map::Map::new();    if let Some(env) = settings_obj.get("env").and_then(JsonValue::as_object)        && !env.is_empty()    {        let mut shell_policy = toml::map::Map::new();        shell_policy.insert("inherit".to_string(), TomlValue::String("core".to_string()));        shell_policy.insert(            "set".to_string(),            TomlValue::Table(json_object_to_env_toml_table(env)),        );        root.insert(            "shell_environment_policy".to_string(),            TomlValue::Table(shell_policy),        );    }    if let Some(sandbox_enabled) = settings_obj        .get("sandbox")        .and_then(JsonValue::as_object)        .and_then(|sandbox| sandbox.get("enabled"))        .and_then(JsonValue::as_bool)        && sandbox_enabled    {        root.insert(            "sandbox_mode".to_string(),            TomlValue::String("workspace-write".to_string()),        );    }    Ok(TomlValue::Table(root))}fn json_object_to_env_toml_table(    object: &serde_json::Map<String, JsonValue>,) -> toml::map::Map<String, TomlValue> {    let mut table = toml::map::Map::new();    for (key, value) in object {        if let Some(value) = json_env_value_to_string(value) {            table.insert(key.clone(), TomlValue::String(value));        }    }    table}fn json_env_value_to_string(value: &JsonValue) -> Option<String> {    match value {        JsonValue::String(value) => Some(value.clone()),        JsonValue::Null => None,        JsonValue::Bool(value) => Some(value.to_string()),        JsonValue::Number(value) => Some(value.to_string()),        JsonValue::Array(_) | JsonValue::Object(_) => None,    }}fn merge_missing_toml_values(existing: &mut TomlValue, incoming: &TomlValue) -> io::Result<bool> {    match (existing, incoming) {        (TomlValue::Table(existing_table), TomlValue::Table(incoming_table)) => {            let mut changed = false;            for (key, incoming_value) in incoming_table {                match existing_table.get_mut(key) {                    Some(existing_value) => {                        if matches!(                            (&*existing_value, incoming_value),                            (TomlValue::Table(_), TomlValue::Table(_))                        ) && merge_missing_toml_values(existing_value, incoming_value)?                        {                            changed = true;                        }                    }                    None => {                        existing_table.insert(key.clone(), incoming_value.clone());                        changed = true;                    }                }            }            Ok(changed)        }        _ => Err(invalid_data_error(            "expected TOML table while merging migrated config values",        )),    }}fn merge_missing_mcp_servers(    existing: &mut TomlValue,    incoming: &TomlValue,) -> io::Result<Vec<String>> {    let existing_root = existing        .as_table_mut()        .ok_or_else(|| invalid_data_error("expected existing config to be a TOML table"))?;    let incoming_root = incoming        .as_table()        .ok_or_else(|| invalid_data_error("expected migrated MCP config to be a TOML table"))?;    let Some(incoming_servers) = incoming_root.get("mcp_servers") else {        return Ok(Vec::new());    };    let incoming_servers = incoming_servers        .as_table()        .ok_or_else(|| invalid_data_error("expected migrated MCP servers to be a TOML table"))?;    let Some(existing_servers) = existing_root.get_mut("mcp_servers") else {        existing_root.insert(            "mcp_servers".to_string(),            TomlValue::Table(incoming_servers.clone()),        );        return Ok(incoming_servers.keys().cloned().collect());    };    let Some(existing_servers) = existing_servers.as_table_mut() else {        return Ok(Vec::new());    };    let mut merged_server_names = Vec::new();    for (server_name, incoming_server) in incoming_servers {        if !existing_servers.contains_key(server_name) {            existing_servers.insert(server_name.clone(), incoming_server.clone());            merged_server_names.push(server_name.clone());        }    }    Ok(merged_server_names)}fn write_toml_file(path: &Path, value: &TomlValue) -> io::Result<()> {    let serialized = toml::to_string_pretty(value)        .map_err(|err| invalid_data_error(format!("failed to serialize config.toml: {err}")))?;    fs::write(path, format!("{}\n", serialized.trim_end()))}fn migrated_mcp_server_names(value: &TomlValue) -> Vec<String> {    value        .get("mcp_servers")        .and_then(TomlValue::as_table)        .map(|servers| servers.keys().cloned().collect())        .unwrap_or_default()}fn named_migrations(names: Vec<String>) -> Vec<NamedMigration> {    names        .into_iter()        .map(|name| NamedMigration { name })        .collect()}fn is_empty_toml_table(value: &TomlValue) -> bool {    match value {        TomlValue::Table(table) => table.is_empty(),        TomlValue::String(_)        | TomlValue::Integer(_)        | TomlValue::Float(_)        | TomlValue::Boolean(_)        | TomlValue::Datetime(_)        | TomlValue::Array(_) => false,    }}fn invalid_data_error(message: impl Into<String>) -> io::Error {    io::Error::new(io::ErrorKind::InvalidData, message.into())}fn migration_item_type_label(item_type: ExternalAgentConfigMigrationItemType) -> &'static str {    match item_type {        ExternalAgentConfigMigrationItemType::Config => "config",        ExternalAgentConfigMigrationItemType::Skills => "skills",        ExternalAgentConfigMigrationItemType::AgentsMd => "agents_md",        ExternalAgentConfigMigrationItemType::Plugins => "plugins",        ExternalAgentConfigMigrationItemType::McpServerConfig => "mcp_server_config",        ExternalAgentConfigMigrationItemType::Subagents => "subagents",        ExternalAgentConfigMigrationItemType::Hooks => "hooks",        ExternalAgentConfigMigrationItemType::Commands => "commands",        ExternalAgentConfigMigrationItemType::Sessions => "sessions",    }}pub(crate) fn record_import_error(    result: &mut ExternalAgentConfigImportItemResult,    failure_stage: &'static str,    message: impl Into<String>,    source: Option<String>,) {    result.record_error(ExternalAgentConfigImportRawError {        item_type: result.item_type,        error_type: None,        failure_stage: failure_stage.to_string(),        message: message.into(),        cwd: result.cwd.clone(),        source,    });}fn record_plugin_import_errors(    outcome: &mut PluginImportOutcome,    cwd: Option<&Path>,    plugin_ids: &[String],    failure_stage: &'static str,    message: impl Into<String>,) {    let message = message.into();    outcome        .raw_errors        .extend(plugin_ids.iter().map(|plugin_id| {            plugin_import_raw_error(cwd, failure_stage, message.clone(), Some(plugin_id.clone()))        }));}fn plugin_import_raw_error(    cwd: Option<&Path>,    failure_stage: &'static str,    message: String,    source: Option<String>,) -> ExternalAgentConfigImportRawError {    ExternalAgentConfigImportRawError {        item_type: ExternalAgentConfigMigrationItemType::Plugins,        error_type: None,        failure_stage: failure_stage.to_string(),        message,        cwd: cwd.map(Path::to_path_buf),        source,    }}fn migration_metric_tags(    item_type: ExternalAgentConfigMigrationItemType,    skills_count: Option<usize>,) -> Vec<(&'static str, String)> {    let mut tags = vec![(        "migration_type",        migration_item_type_label(item_type).to_string(),    )];    if matches!(        item_type,        ExternalAgentConfigMigrationItemType::Skills            | ExternalAgentConfigMigrationItemType::Subagents            | ExternalAgentConfigMigrationItemType::Commands    ) {        tags.push(("skills_count", skills_count.unwrap_or(0).to_string()));    }    tags}fn emit_migration_metric(    metric_name: &str,    item_type: ExternalAgentConfigMigrationItemType,    skills_count: Option<usize>,) {    let Some(metrics) = codex_otel::global() else {        return;    };    let tags = migration_metric_tags(item_type, skills_count);    let tag_refs = tags        .iter()        .map(|(key, value)| (*key, value.as_str()))        .collect::<Vec<_>>();    let _ = metrics.counter(metric_name, /*inc*/ 1, &tag_refs);}#[cfg(test)]#[path = "external_agent_config_tests.rs"]mod tests;