Files
codex/codex-rs/app-server/src/config/external_agent_config.rs
T
xl-openai 7011044c1c [codex] Skip local curated discovery for remote plugins (#27311)
## Summary

- skip the local `openai-curated` marketplace before marketplace loading
when tool-suggest discovery uses remote plugins
- preserve existing marketplace listing behavior for all other callers
and when remote plugins are disabled
- add regression coverage proving the curated marketplace is excluded
before its malformed manifest can be read

## Why

Tool-suggest discovery previously loaded every local `openai-curated`
plugin manifest and only discarded that marketplace afterward when
remote plugins were enabled. The remote catalog is used in that mode, so
the local scan consumed CPU without contributing discoverable plugins.

## Impact

Remote-plugin tool suggestion discovery no longer reads the local
curated marketplace and its plugin manifests. `openai-bundled`,
configured marketplaces, normal `plugin/list` behavior, and local
curated discovery when remote plugins are disabled are unchanged.

## Validation

- `just test -p codex-core-plugins
list_marketplaces_can_skip_openai_curated_before_loading`
- `just test -p codex-core
list_tool_suggest_discoverable_plugins_omits_openai_curated_when_remote_enabled`
- `just fmt`
- `git diff --check`
2026-06-10 13:11:09 -07:00

1660 lines
60 KiB
Rust

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 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>,
}
#[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<Vec<PendingPluginImport>> {
let mut pending_plugin_imports = Vec::new();
for migration_item in migration_items {
match migration_item.item_type {
ExternalAgentConfigMigrationItemType::Config => {
self.import_config(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Config,
/*skills_count*/ None,
);
}
ExternalAgentConfigMigrationItemType::Skills => {
let skills_count = self.import_skills(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Skills,
Some(skills_count),
);
}
ExternalAgentConfigMigrationItemType::AgentsMd => {
self.import_agents_md(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::AgentsMd,
/*skills_count*/ None,
);
}
ExternalAgentConfigMigrationItemType::Plugins => {
let cwd = migration_item.cwd;
let details = migration_item.details.ok_or_else(|| {
invalid_data_error("plugins migration item is missing details".to_string())
})?;
let (local_details, remote_details) =
self.partition_plugin_migration_details(cwd.as_deref(), details)?;
if let Some(local_details) = local_details {
self.import_plugins(cwd.as_deref(), Some(local_details))
.await?;
}
if let Some(remote_details) = remote_details {
pending_plugin_imports.push(PendingPluginImport {
cwd,
details: remote_details,
});
}
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Plugins,
/*skills_count*/ None,
);
}
ExternalAgentConfigMigrationItemType::McpServerConfig => {
self.import_mcp_server_config(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::McpServerConfig,
/*skills_count*/ None,
);
}
ExternalAgentConfigMigrationItemType::Subagents => {
let subagents_count = self.import_subagents(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Subagents,
Some(subagents_count),
);
}
ExternalAgentConfigMigrationItemType::Hooks => {
self.import_hooks(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Hooks,
/*skills_count*/ None,
);
}
ExternalAgentConfigMigrationItemType::Commands => {
let commands_count = self.import_commands(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Commands,
Some(commands_count),
);
}
ExternalAgentConfigMigrationItemType::Sessions => {}
}
}
Ok(pending_plugin_imports)
}
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 {
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 {
outcome.failed_marketplaces.push(marketplace_name);
outcome.failed_plugin_ids.extend(plugin_ids);
continue;
};
outcome
.succeeded_marketplaces
.push(marketplace_name.clone());
marketplace_path
}
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 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(());
} 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(());
};
let migrated = build_config_from_external(&settings)?;
if is_empty_toml_table(&migrated) {
return Ok(());
}
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(());
}
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(());
}
write_toml_file(&target_config, &existing)?;
Ok(())
}
fn import_mcp_server_config(&self, cwd: Option<&Path>) -> io::Result<()> {
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(());
} 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(());
}
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(());
}
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}")))?
};
if !merge_missing_mcp_servers(&mut existing, &migrated)?.is_empty() {
write_toml_file(&target_config, &existing)?;
}
Ok(())
}
fn import_subagents(&self, cwd: Option<&Path>) -> io::Result<usize> {
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(0);
} 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<()> {
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(());
} else {
(
self.external_agent_home.clone(),
self.codex_home.join("hooks.json"),
)
};
import_hooks(&source_external_agent_dir, &target_hooks)?;
Ok(())
}
fn import_commands(&self, cwd: Option<&Path>) -> io::Result<usize> {
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(0);
} 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<usize> {
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(0);
} else {
(
self.external_agent_home.join("skills"),
self.home_target_skills_dir(),
)
};
if !source_skills.is_dir() {
return Ok(0);
}
fs::create_dir_all(&target_skills)?;
let mut copied_count = 0usize;
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_count += 1;
}
Ok(copied_count)
}
fn import_agents_md(&self, cwd: Option<&Path>) -> io::Result<()> {
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(());
};
(source_agents_md, repo_root.join("AGENTS.md"))
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(());
} 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(());
}
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)
}
}
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_metric_tags(
item_type: ExternalAgentConfigMigrationItemType,
skills_count: Option<usize>,
) -> Vec<(&'static str, String)> {
let migration_type = 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",
};
let mut tags = vec![("migration_type", migration_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;