Move external agent migration into its crate (#33456)

## What changed

- Move migration detection, import orchestration, models, and reporting from
  `codex-app-server` into `codex-external-agent-migration`.
- Keep app-server protocol conversion in a dedicated boundary module.
- Organize source-specific configuration, sessions, hooks, plugins, commands,
  subagents, and memory handling behind shared migration adapters.
- Relocate and split the migration tests alongside the extracted service.

GitOrigin-RevId: 3378ec2064806a28136d6ac98664ca1d75414a4e
This commit is contained in:
charlesgong-openai
2026-07-16 00:34:36 +00:00
committed by copyberry
parent 2edad72de3
commit e7efc5b04b
57 changed files with 7713 additions and 7471 deletions
+10
View File
@@ -3040,9 +3040,17 @@ name = "codex-external-agent-migration"
version = "0.0.0"
dependencies = [
"chrono",
"codex-analytics",
"codex-app-server-protocol",
"codex-config",
"codex-core",
"codex-core-plugins",
"codex-hooks",
"codex-memories-write",
"codex-otel",
"codex-plugin",
"codex-protocol",
"codex-rollout",
"codex-utils-output-truncation",
"pretty_assertions",
"serde",
@@ -3050,7 +3058,9 @@ dependencies = [
"serde_yaml",
"sha2 0.10.9",
"tempfile",
"tokio",
"toml 0.9.11+spec-1.1.0",
"tracing",
]
[[package]]
@@ -1,5 +1,5 @@
mod processor;
mod service;
mod protocol;
mod session_importer;
pub(crate) use processor::ExternalAgentConfigRequestProcessor;
@@ -9,31 +9,29 @@ use crate::request_processors::ConfigRequestProcessor;
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::ExternalAgentConfigImportCompletedInput;
use codex_analytics::ExternalAgentConfigImportFailureInput;
use codex_app_server_protocol::CommandMigration;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigDetectResponse;
use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification;
use codex_app_server_protocol::ExternalAgentConfigImportHistoriesReadResponse;
use codex_app_server_protocol::ExternalAgentConfigImportHistory;
use codex_app_server_protocol::ExternalAgentConfigImportItemTypeFailure as ProtocolImportFailure;
use codex_app_server_protocol::ExternalAgentConfigImportItemTypeSuccess as ProtocolImportSuccess;
use codex_app_server_protocol::ExternalAgentConfigImportParams;
use codex_app_server_protocol::ExternalAgentConfigImportProgressNotification;
use codex_app_server_protocol::ExternalAgentConfigImportResponse;
use codex_app_server_protocol::ExternalAgentConfigImportTypeResult as ProtocolImportTypeResult;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use codex_app_server_protocol::ExternalAgentImportedConnectorCandidate;
use codex_app_server_protocol::ExternalAgentImportedConnectorSource;
use codex_app_server_protocol::HookMigration;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::McpServerMigration;
use codex_app_server_protocol::MigrationDetails;
use codex_app_server_protocol::PluginsMigration;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::SkillMigration;
use codex_arg0::Arg0DispatchPaths;
use codex_core::ThreadManager;
use codex_external_agent_migration::ExternalAgentConfigDetectOptions;
use codex_external_agent_migration::ExternalAgentConfigImportItemResult as CoreImportItemResult;
use codex_external_agent_migration::ExternalAgentConfigImportOutcome as CoreImportOutcome;
use codex_external_agent_migration::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
use codex_external_agent_migration::ExternalAgentConfigService;
use codex_external_agent_migration::PluginImportOutcome;
use codex_external_agent_migration::record_import_error;
use codex_external_agent_migration::sessions::ExternalAgentSessionMigration as CoreSessionMigration;
use codex_external_agent_migration::sessions::read_imported_connector_candidates;
use codex_features::Feature;
@@ -44,16 +42,11 @@ use codex_thread_store::ThreadStore;
use std::collections::HashSet;
use std::path::PathBuf;
use super::service::ExternalAgentConfigDetectOptions;
use super::service::ExternalAgentConfigImportItemResult as CoreImportItemResult;
use super::service::ExternalAgentConfigImportOutcome as CoreImportOutcome;
use super::service::ExternalAgentConfigImportRawError as CoreImportRawError;
use super::service::ExternalAgentConfigMigrationItem as CoreMigrationItem;
use super::service::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
use super::service::ExternalAgentConfigService;
use super::service::NamedMigration as CoreNamedMigration;
use super::service::PluginImportOutcome;
use super::service::record_import_error;
use super::protocol::completed_notification;
use super::protocol::core_migration_items;
use super::protocol::detect_response;
use super::protocol::protocol_import_history;
use super::protocol::protocol_import_type_result;
use super::session_importer::ExternalAgentSessionImporter;
use uuid::Uuid;
@@ -101,7 +94,7 @@ impl ExternalAgentConfigRequestProcessor {
);
let session_importer = ExternalAgentSessionImporter::new(
codex_home,
migration_service.connector_metadata_roots.clone(),
migration_service.connector_metadata_roots().to_vec(),
Arc::clone(&thread_manager),
thread_store,
config_manager.clone(),
@@ -136,94 +129,7 @@ impl ExternalAgentConfigRequestProcessor {
.await
.map_err(|err| internal_error(err.to_string()))?;
Ok(ExternalAgentConfigDetectResponse {
items: items
.into_iter()
.map(|migration_item| ExternalAgentConfigMigrationItem {
item_type: match migration_item.item_type {
CoreMigrationItemType::Config => {
ExternalAgentConfigMigrationItemType::Config
}
CoreMigrationItemType::Skills => {
ExternalAgentConfigMigrationItemType::Skills
}
CoreMigrationItemType::AgentsMd => {
ExternalAgentConfigMigrationItemType::AgentsMd
}
CoreMigrationItemType::Plugins => {
ExternalAgentConfigMigrationItemType::Plugins
}
CoreMigrationItemType::McpServerConfig => {
ExternalAgentConfigMigrationItemType::McpServerConfig
}
CoreMigrationItemType::Subagents => {
ExternalAgentConfigMigrationItemType::Subagents
}
CoreMigrationItemType::Hooks => ExternalAgentConfigMigrationItemType::Hooks,
CoreMigrationItemType::Commands => {
ExternalAgentConfigMigrationItemType::Commands
}
CoreMigrationItemType::Memory => {
ExternalAgentConfigMigrationItemType::Memory
}
CoreMigrationItemType::Sessions => {
ExternalAgentConfigMigrationItemType::Sessions
}
},
description: migration_item.description,
cwd: migration_item.cwd,
details: migration_item.details.map(|details| MigrationDetails {
plugins: details
.plugins
.into_iter()
.map(|plugin| PluginsMigration {
marketplace_name: plugin.marketplace_name,
plugin_names: plugin.plugin_names,
})
.collect(),
skills: details
.skills
.into_iter()
.map(|skill| SkillMigration { name: skill.name })
.collect(),
sessions: details
.sessions
.into_iter()
.map(|session| codex_app_server_protocol::SessionMigration {
path: session.path,
cwd: session.cwd,
title: session.title,
})
.collect(),
mcp_servers: details
.mcp_servers
.into_iter()
.map(|mcp_server| McpServerMigration {
name: mcp_server.name,
})
.collect(),
hooks: details
.hooks
.into_iter()
.map(|hook| HookMigration { name: hook.name })
.collect(),
subagents: details
.subagents
.into_iter()
.map(|subagent| codex_app_server_protocol::SubagentMigration {
name: subagent.name,
})
.collect(),
commands: details
.commands
.into_iter()
.map(|command| CommandMigration { name: command.name })
.collect(),
memory: details.memory,
}),
})
.collect(),
})
Ok(detect_response(items))
}
pub(crate) async fn import(
@@ -437,7 +343,7 @@ impl ExternalAgentConfigRequestProcessor {
.into_iter()
.map(protocol_import_history)
.collect::<Result<Vec<_>, _>>()?;
let connectors = read_imported_connector_candidates(&self.migration_service.codex_home)
let connectors = read_imported_connector_candidates(self.migration_service.codex_home())
.map_err(|err| {
internal_error(format!(
"failed to read imported connector candidates: {err}"
@@ -525,105 +431,13 @@ impl ExternalAgentConfigRequestProcessor {
migration_service: &ExternalAgentConfigService,
) -> CoreImportOutcome {
migration_service
.import(
.import(core_migration_items(
params
.migration_items
.into_iter()
.filter(|migration_item| {
!matches!(
migration_item.item_type,
ExternalAgentConfigMigrationItemType::Sessions
)
})
.map(|migration_item| CoreMigrationItem {
item_type: match migration_item.item_type {
ExternalAgentConfigMigrationItemType::Config => {
CoreMigrationItemType::Config
}
ExternalAgentConfigMigrationItemType::Skills => {
CoreMigrationItemType::Skills
}
ExternalAgentConfigMigrationItemType::AgentsMd => {
CoreMigrationItemType::AgentsMd
}
ExternalAgentConfigMigrationItemType::Plugins => {
CoreMigrationItemType::Plugins
}
ExternalAgentConfigMigrationItemType::McpServerConfig => {
CoreMigrationItemType::McpServerConfig
}
ExternalAgentConfigMigrationItemType::Subagents => {
CoreMigrationItemType::Subagents
}
ExternalAgentConfigMigrationItemType::Hooks => {
CoreMigrationItemType::Hooks
}
ExternalAgentConfigMigrationItemType::Commands => {
CoreMigrationItemType::Commands
}
ExternalAgentConfigMigrationItemType::Memory => {
CoreMigrationItemType::Memory
}
ExternalAgentConfigMigrationItemType::Sessions => {
CoreMigrationItemType::Sessions
}
},
description: migration_item.description,
cwd: migration_item.cwd,
details: migration_item.details.map(|details| {
super::service::MigrationDetails {
plugins: details
.plugins
.into_iter()
.map(|plugin| super::service::PluginsMigration {
marketplace_name: plugin.marketplace_name,
plugin_names: plugin.plugin_names,
})
.collect(),
skills: details
.skills
.into_iter()
.map(|skill| CoreNamedMigration { name: skill.name })
.collect(),
sessions: details
.sessions
.into_iter()
.map(|session| CoreSessionMigration {
path: session.path,
cwd: session.cwd,
title: session.title,
})
.collect(),
mcp_servers: details
.mcp_servers
.into_iter()
.map(|mcp_server| CoreNamedMigration {
name: mcp_server.name,
})
.collect(),
hooks: details
.hooks
.into_iter()
.map(|hook| CoreNamedMigration { name: hook.name })
.collect(),
subagents: details
.subagents
.into_iter()
.map(|subagent| CoreNamedMigration {
name: subagent.name,
})
.collect(),
commands: details
.commands
.into_iter()
.map(|command| CoreNamedMigration { name: command.name })
.collect(),
memory: details.memory,
}
}),
})
.filter(|item| item.item_type != ExternalAgentConfigMigrationItemType::Sessions)
.collect(),
)
))
.await
}
}
@@ -783,171 +597,6 @@ async fn record_completed_import_notification(
.await
}
fn protocol_import_history(
record: codex_state::ExternalAgentConfigImportHistoryRecord,
) -> Result<ExternalAgentConfigImportHistory, JSONRPCErrorError> {
let successes = record
.successes
.into_iter()
.map(protocol_import_success_record)
.collect::<Result<Vec<_>, _>>()?;
let failures = record
.failures
.into_iter()
.map(protocol_import_failure_record)
.collect::<Result<Vec<_>, _>>()?;
Ok(ExternalAgentConfigImportHistory {
import_id: record.import_id,
completed_at_ms: record.completed_at_ms,
successes,
failures,
})
}
fn protocol_import_success_record(
record: ExternalAgentConfigImportSuccessRecord,
) -> Result<ProtocolImportSuccess, JSONRPCErrorError> {
Ok(ProtocolImportSuccess {
item_type: protocol_import_record_item_type(record.item_type)?,
cwd: record.cwd,
source: record.source,
target: record.target,
})
}
fn protocol_import_failure_record(
record: ExternalAgentConfigImportFailureRecord,
) -> Result<ProtocolImportFailure, JSONRPCErrorError> {
Ok(ProtocolImportFailure {
item_type: protocol_import_record_item_type(record.item_type)?,
error_type: record.error_type,
sub_error_type: record.sub_error_type,
failure_stage: record.failure_stage,
message: record.message,
cwd: record.cwd,
source: record.source,
})
}
fn protocol_import_record_item_type(
item_type: String,
) -> Result<ExternalAgentConfigMigrationItemType, JSONRPCErrorError> {
serde_json::from_value(serde_json::Value::String(item_type.clone())).map_err(|err| {
internal_error(format!(
"failed to decode import item type {item_type}: {err}"
))
})
}
fn completed_notification(
import_id: String,
item_results: &[CoreImportItemResult],
) -> ExternalAgentConfigImportCompletedNotification {
let mut protocol_type_results: Vec<ProtocolImportTypeResult> = Vec::new();
for item_result in item_results {
let item_raw_errors = item_result
.raw_errors
.iter()
.map(protocol_import_raw_error)
.collect::<Vec<_>>();
let item_successes = item_result
.successes
.iter()
.map(protocol_import_success)
.collect::<Vec<_>>();
let item_type = protocol_migration_item_type(item_result.item_type);
if let Some(type_result) = protocol_type_results
.iter_mut()
.find(|type_result| type_result.item_type == item_type)
{
type_result.successes.extend(item_successes);
type_result.failures.extend(item_raw_errors);
} else {
protocol_type_results.push(ProtocolImportTypeResult {
item_type,
successes: item_successes,
failures: item_raw_errors,
});
}
}
protocol_type_results.sort_by_key(|type_result| match type_result.item_type {
ExternalAgentConfigMigrationItemType::Config => 0,
ExternalAgentConfigMigrationItemType::Skills => 1,
ExternalAgentConfigMigrationItemType::AgentsMd => 2,
ExternalAgentConfigMigrationItemType::Plugins => 3,
ExternalAgentConfigMigrationItemType::McpServerConfig => 4,
ExternalAgentConfigMigrationItemType::Subagents => 5,
ExternalAgentConfigMigrationItemType::Hooks => 6,
ExternalAgentConfigMigrationItemType::Commands => 7,
ExternalAgentConfigMigrationItemType::Sessions => 8,
ExternalAgentConfigMigrationItemType::Memory => 9,
});
ExternalAgentConfigImportCompletedNotification {
import_id,
item_type_results: protocol_type_results,
}
}
fn protocol_import_type_result(item_result: &CoreImportItemResult) -> ProtocolImportTypeResult {
ProtocolImportTypeResult {
item_type: protocol_migration_item_type(item_result.item_type),
successes: item_result
.successes
.iter()
.map(protocol_import_success)
.collect(),
failures: item_result
.raw_errors
.iter()
.map(protocol_import_raw_error)
.collect(),
}
}
fn protocol_import_success(
success: &super::service::ExternalAgentConfigImportSuccess,
) -> ProtocolImportSuccess {
ProtocolImportSuccess {
item_type: protocol_migration_item_type(success.item_type),
cwd: success.cwd.clone(),
source: success.source.clone(),
target: success.target.clone(),
}
}
fn protocol_import_raw_error(raw_error: &CoreImportRawError) -> ProtocolImportFailure {
ProtocolImportFailure {
item_type: protocol_migration_item_type(raw_error.item_type),
error_type: raw_error.error_type.clone(),
sub_error_type: raw_error.sub_error_type.clone(),
failure_stage: raw_error.failure_stage.clone(),
message: raw_error.message.clone(),
cwd: raw_error.cwd.clone(),
source: raw_error.source.clone(),
}
}
fn protocol_migration_item_type(
item_type: CoreMigrationItemType,
) -> ExternalAgentConfigMigrationItemType {
match item_type {
CoreMigrationItemType::Config => ExternalAgentConfigMigrationItemType::Config,
CoreMigrationItemType::Skills => ExternalAgentConfigMigrationItemType::Skills,
CoreMigrationItemType::AgentsMd => ExternalAgentConfigMigrationItemType::AgentsMd,
CoreMigrationItemType::Plugins => ExternalAgentConfigMigrationItemType::Plugins,
CoreMigrationItemType::McpServerConfig => {
ExternalAgentConfigMigrationItemType::McpServerConfig
}
CoreMigrationItemType::Subagents => ExternalAgentConfigMigrationItemType::Subagents,
CoreMigrationItemType::Hooks => ExternalAgentConfigMigrationItemType::Hooks,
CoreMigrationItemType::Commands => ExternalAgentConfigMigrationItemType::Commands,
CoreMigrationItemType::Memory => ExternalAgentConfigMigrationItemType::Memory,
CoreMigrationItemType::Sessions => ExternalAgentConfigMigrationItemType::Sessions,
}
}
fn apply_plugin_outcome_to_item_result(
item_result: &mut CoreImportItemResult,
plugin_outcome: PluginImportOutcome,
@@ -0,0 +1,341 @@
use crate::error_code::internal_error;
use codex_app_server_protocol::CommandMigration;
use codex_app_server_protocol::ExternalAgentConfigDetectResponse;
use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification;
use codex_app_server_protocol::ExternalAgentConfigImportHistory;
use codex_app_server_protocol::ExternalAgentConfigImportItemTypeFailure as ProtocolImportFailure;
use codex_app_server_protocol::ExternalAgentConfigImportItemTypeSuccess as ProtocolImportSuccess;
use codex_app_server_protocol::ExternalAgentConfigImportTypeResult as ProtocolImportTypeResult;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use codex_app_server_protocol::HookMigration;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::McpServerMigration;
use codex_app_server_protocol::MigrationDetails;
use codex_app_server_protocol::PluginsMigration;
use codex_app_server_protocol::SkillMigration;
use codex_app_server_protocol::SubagentMigration;
use codex_external_agent_migration::ExternalAgentConfigImportItemResult as CoreImportItemResult;
use codex_external_agent_migration::ExternalAgentConfigImportRawError as CoreImportRawError;
use codex_external_agent_migration::ExternalAgentConfigImportSuccess;
use codex_external_agent_migration::ExternalAgentConfigMigrationItem as CoreMigrationItem;
use codex_external_agent_migration::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
use codex_external_agent_migration::MigrationDetails as CoreMigrationDetails;
use codex_external_agent_migration::NamedMigration;
use codex_external_agent_migration::PluginsMigration as CorePluginsMigration;
use codex_external_agent_migration::sessions::ExternalAgentSessionMigration;
use codex_state::ExternalAgentConfigImportFailureRecord;
use codex_state::ExternalAgentConfigImportSuccessRecord;
pub(super) fn detect_response(items: Vec<CoreMigrationItem>) -> ExternalAgentConfigDetectResponse {
ExternalAgentConfigDetectResponse {
items: items.into_iter().map(protocol_migration_item).collect(),
}
}
fn protocol_migration_item(item: CoreMigrationItem) -> ExternalAgentConfigMigrationItem {
ExternalAgentConfigMigrationItem {
item_type: protocol_migration_item_type(item.item_type),
description: item.description,
cwd: item.cwd,
details: item.details.map(protocol_migration_details),
}
}
fn protocol_migration_details(details: CoreMigrationDetails) -> MigrationDetails {
MigrationDetails {
plugins: details
.plugins
.into_iter()
.map(|plugin| PluginsMigration {
marketplace_name: plugin.marketplace_name,
plugin_names: plugin.plugin_names,
})
.collect(),
skills: details
.skills
.into_iter()
.map(|skill| SkillMigration { name: skill.name })
.collect(),
sessions: details
.sessions
.into_iter()
.map(|session| codex_app_server_protocol::SessionMigration {
path: session.path,
cwd: session.cwd,
title: session.title,
})
.collect(),
mcp_servers: details
.mcp_servers
.into_iter()
.map(|server| McpServerMigration { name: server.name })
.collect(),
hooks: details
.hooks
.into_iter()
.map(|hook| HookMigration { name: hook.name })
.collect(),
subagents: details
.subagents
.into_iter()
.map(|subagent| SubagentMigration {
name: subagent.name,
})
.collect(),
commands: details
.commands
.into_iter()
.map(|command| CommandMigration { name: command.name })
.collect(),
memory: details.memory,
}
}
pub(super) fn core_migration_items(
items: Vec<ExternalAgentConfigMigrationItem>,
) -> Vec<CoreMigrationItem> {
items
.into_iter()
.map(|item| CoreMigrationItem {
item_type: core_migration_item_type(item.item_type),
description: item.description,
cwd: item.cwd,
details: item.details.map(core_migration_details),
})
.collect()
}
fn core_migration_details(details: MigrationDetails) -> CoreMigrationDetails {
CoreMigrationDetails {
plugins: details
.plugins
.into_iter()
.map(|plugin| CorePluginsMigration {
marketplace_name: plugin.marketplace_name,
plugin_names: plugin.plugin_names,
})
.collect(),
skills: details
.skills
.into_iter()
.map(|skill| NamedMigration { name: skill.name })
.collect(),
sessions: details
.sessions
.into_iter()
.map(|session| ExternalAgentSessionMigration {
path: session.path,
cwd: session.cwd,
title: session.title,
})
.collect(),
mcp_servers: details
.mcp_servers
.into_iter()
.map(|server| NamedMigration { name: server.name })
.collect(),
hooks: details
.hooks
.into_iter()
.map(|hook| NamedMigration { name: hook.name })
.collect(),
subagents: details
.subagents
.into_iter()
.map(|subagent| NamedMigration {
name: subagent.name,
})
.collect(),
commands: details
.commands
.into_iter()
.map(|command| NamedMigration { name: command.name })
.collect(),
memory: details.memory,
}
}
pub(super) fn protocol_migration_item_type(
item_type: CoreMigrationItemType,
) -> ExternalAgentConfigMigrationItemType {
match item_type {
CoreMigrationItemType::Config => ExternalAgentConfigMigrationItemType::Config,
CoreMigrationItemType::Skills => ExternalAgentConfigMigrationItemType::Skills,
CoreMigrationItemType::AgentsMd => ExternalAgentConfigMigrationItemType::AgentsMd,
CoreMigrationItemType::Plugins => ExternalAgentConfigMigrationItemType::Plugins,
CoreMigrationItemType::McpServerConfig => {
ExternalAgentConfigMigrationItemType::McpServerConfig
}
CoreMigrationItemType::Subagents => ExternalAgentConfigMigrationItemType::Subagents,
CoreMigrationItemType::Hooks => ExternalAgentConfigMigrationItemType::Hooks,
CoreMigrationItemType::Commands => ExternalAgentConfigMigrationItemType::Commands,
CoreMigrationItemType::Memory => ExternalAgentConfigMigrationItemType::Memory,
CoreMigrationItemType::Sessions => ExternalAgentConfigMigrationItemType::Sessions,
}
}
fn core_migration_item_type(
item_type: ExternalAgentConfigMigrationItemType,
) -> CoreMigrationItemType {
match item_type {
ExternalAgentConfigMigrationItemType::Config => CoreMigrationItemType::Config,
ExternalAgentConfigMigrationItemType::Skills => CoreMigrationItemType::Skills,
ExternalAgentConfigMigrationItemType::AgentsMd => CoreMigrationItemType::AgentsMd,
ExternalAgentConfigMigrationItemType::Plugins => CoreMigrationItemType::Plugins,
ExternalAgentConfigMigrationItemType::McpServerConfig => {
CoreMigrationItemType::McpServerConfig
}
ExternalAgentConfigMigrationItemType::Subagents => CoreMigrationItemType::Subagents,
ExternalAgentConfigMigrationItemType::Hooks => CoreMigrationItemType::Hooks,
ExternalAgentConfigMigrationItemType::Commands => CoreMigrationItemType::Commands,
ExternalAgentConfigMigrationItemType::Memory => CoreMigrationItemType::Memory,
ExternalAgentConfigMigrationItemType::Sessions => CoreMigrationItemType::Sessions,
}
}
pub(super) fn protocol_import_history(
record: codex_state::ExternalAgentConfigImportHistoryRecord,
) -> Result<ExternalAgentConfigImportHistory, JSONRPCErrorError> {
let successes = record
.successes
.into_iter()
.map(protocol_import_success_record)
.collect::<Result<Vec<_>, _>>()?;
let failures = record
.failures
.into_iter()
.map(protocol_import_failure_record)
.collect::<Result<Vec<_>, _>>()?;
Ok(ExternalAgentConfigImportHistory {
import_id: record.import_id,
completed_at_ms: record.completed_at_ms,
successes,
failures,
})
}
fn protocol_import_success_record(
record: ExternalAgentConfigImportSuccessRecord,
) -> Result<ProtocolImportSuccess, JSONRPCErrorError> {
Ok(ProtocolImportSuccess {
item_type: protocol_import_record_item_type(record.item_type)?,
cwd: record.cwd,
source: record.source,
target: record.target,
})
}
fn protocol_import_failure_record(
record: ExternalAgentConfigImportFailureRecord,
) -> Result<ProtocolImportFailure, JSONRPCErrorError> {
Ok(ProtocolImportFailure {
item_type: protocol_import_record_item_type(record.item_type)?,
error_type: record.error_type,
sub_error_type: record.sub_error_type,
failure_stage: record.failure_stage,
message: record.message,
cwd: record.cwd,
source: record.source,
})
}
fn protocol_import_record_item_type(
item_type: String,
) -> Result<ExternalAgentConfigMigrationItemType, JSONRPCErrorError> {
serde_json::from_value(serde_json::Value::String(item_type.clone())).map_err(|err| {
internal_error(format!(
"failed to decode import item type {item_type}: {err}"
))
})
}
pub(super) fn completed_notification(
import_id: String,
item_results: &[CoreImportItemResult],
) -> ExternalAgentConfigImportCompletedNotification {
let mut protocol_type_results: Vec<ProtocolImportTypeResult> = Vec::new();
for item_result in item_results {
let item_raw_errors = item_result
.raw_errors
.iter()
.map(protocol_import_raw_error)
.collect::<Vec<_>>();
let item_successes = item_result
.successes
.iter()
.map(protocol_import_success)
.collect::<Vec<_>>();
let item_type = protocol_migration_item_type(item_result.item_type);
if let Some(type_result) = protocol_type_results
.iter_mut()
.find(|type_result| type_result.item_type == item_type)
{
type_result.successes.extend(item_successes);
type_result.failures.extend(item_raw_errors);
} else {
protocol_type_results.push(ProtocolImportTypeResult {
item_type,
successes: item_successes,
failures: item_raw_errors,
});
}
}
protocol_type_results.sort_by_key(|type_result| match type_result.item_type {
ExternalAgentConfigMigrationItemType::Config => 0,
ExternalAgentConfigMigrationItemType::Skills => 1,
ExternalAgentConfigMigrationItemType::AgentsMd => 2,
ExternalAgentConfigMigrationItemType::Plugins => 3,
ExternalAgentConfigMigrationItemType::McpServerConfig => 4,
ExternalAgentConfigMigrationItemType::Subagents => 5,
ExternalAgentConfigMigrationItemType::Hooks => 6,
ExternalAgentConfigMigrationItemType::Commands => 7,
ExternalAgentConfigMigrationItemType::Sessions => 8,
ExternalAgentConfigMigrationItemType::Memory => 9,
});
ExternalAgentConfigImportCompletedNotification {
import_id,
item_type_results: protocol_type_results,
}
}
pub(super) fn protocol_import_type_result(
item_result: &CoreImportItemResult,
) -> ProtocolImportTypeResult {
ProtocolImportTypeResult {
item_type: protocol_migration_item_type(item_result.item_type),
successes: item_result
.successes
.iter()
.map(protocol_import_success)
.collect(),
failures: item_result
.raw_errors
.iter()
.map(protocol_import_raw_error)
.collect(),
}
}
fn protocol_import_success(success: &ExternalAgentConfigImportSuccess) -> ProtocolImportSuccess {
ProtocolImportSuccess {
item_type: protocol_migration_item_type(success.item_type),
cwd: success.cwd.clone(),
source: success.source.clone(),
target: success.target.clone(),
}
}
fn protocol_import_raw_error(raw_error: &CoreImportRawError) -> ProtocolImportFailure {
ProtocolImportFailure {
item_type: protocol_migration_item_type(raw_error.item_type),
error_type: raw_error.error_type.clone(),
sub_error_type: raw_error.sub_error_type.clone(),
failure_stage: raw_error.failure_stage.clone(),
message: raw_error.message.clone(),
cwd: raw_error.cwd.clone(),
source: raw_error.source.clone(),
}
}
File diff suppressed because it is too large Load Diff
@@ -1,347 +0,0 @@
use codex_core_plugins::CommandDescriptionMode;
use codex_core_plugins::CommandMigrationProfile;
use codex_core_plugins::CommandRewriteProfile;
use codex_core_plugins::count_missing_commands_with_profile;
use codex_core_plugins::import_commands_with_profile;
use codex_core_plugins::missing_command_names_with_profile;
use codex_external_agent_migration::RewriteProfile;
use codex_external_agent_migration::build_mcp_config_from_json_file;
use codex_external_agent_migration::hook_migration_event_names_cur;
use codex_external_agent_migration::import_hooks_cur;
use codex_external_agent_migration::import_subagents_with_rewrite_profile;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
use super::MigrationDetails;
use super::PluginsMigration;
use super::is_non_empty_text_file;
use super::source::DetectedSourcePlugins;
use super::source::InstructionSourceGroup;
use super::source::MarketplaceImportSource;
use super::source::PluginDetectionContext;
pub(super) const CONFIG_DIR: &str = ".cursor";
pub(super) const MIGRATION_SOURCE: &str = "cursor";
const LEGACY_RULES_FILE: &str = ".cursorrules";
pub(super) const HOME_CONFIG_FILE: &str = "cli-config.json";
pub(super) const PROJECT_CONFIG_FILE: &str = "cli.json";
pub(super) const SANDBOX_CONFIG_FILE: &str = "sandbox.json";
pub(super) const HOOKS_CONFIG_FILE: &str = "hooks.json";
const SANDBOX_SETTINGS_KEY: &str = "__cursorSandbox";
const PLUGIN_MARKETPLACE_MANIFEST: &str = ".cursor-plugin/marketplace.json";
pub(super) const REWRITE_PROFILE: RewriteProfile =
RewriteProfile::new(LEGACY_RULES_FILE, &[]).with_case_sensitive_term_variants(&["Cursor"]);
const COMMAND_MIGRATION_PROFILE: CommandMigrationProfile = CommandMigrationProfile::new(
CommandRewriteProfile::new(
REWRITE_PROFILE.doc_file_name(),
REWRITE_PROFILE.term_variants(),
)
.with_case_sensitive_term_variants(REWRITE_PROFILE.case_sensitive_term_variants()),
CommandDescriptionMode::UseSourceNameFallback,
);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct CachedMarketplacePlugins {
pub(super) name: String,
pub(super) source: PathBuf,
pub(super) plugin_names: Vec<String>,
}
pub(super) fn build_mcp_config(source_dir: &Path) -> io::Result<TomlValue> {
build_mcp_config_from_json_file(&source_dir.join("mcp.json"))
}
pub(super) fn repo_instruction_source_groups(
repo_root: &Path,
) -> io::Result<Vec<InstructionSourceGroup>> {
let source = repo_root.join(LEGACY_RULES_FILE);
Ok(is_non_empty_text_file(&source)?
.then(|| InstructionSourceGroup {
scope: repo_root.to_path_buf(),
sources: vec![source],
})
.into_iter()
.collect())
}
pub(super) fn read_instruction_source(path: &Path) -> io::Result<String> {
fs::read_to_string(path)
}
pub(super) fn effective_settings(
source_dir: &Path,
source_settings: &Path,
) -> io::Result<Option<JsonValue>> {
let mut effective = super::read_external_settings(source_settings)?;
let sandbox_settings = super::read_external_settings(&source_dir.join(SANDBOX_CONFIG_FILE))?;
if let Some(sandbox_settings) = sandbox_settings {
let effective = effective.get_or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
let Some(effective) = effective.as_object_mut() else {
return Err(super::invalid_data_error(
"external agent settings root must be an object",
));
};
effective.insert(SANDBOX_SETTINGS_KEY.to_string(), sandbox_settings);
}
Ok(effective)
}
pub(super) fn append_config(
root: &mut toml::map::Map<String, TomlValue>,
settings: &serde_json::Map<String, JsonValue>,
) {
let Some(sandbox) = settings
.get(SANDBOX_SETTINGS_KEY)
.and_then(JsonValue::as_object)
else {
return;
};
let sandbox_mode = match sandbox.get("type").and_then(JsonValue::as_str) {
Some("workspace_readwrite") => Some("workspace-write"),
Some("read_only") => Some("read-only"),
_ => None,
};
if let Some(sandbox_mode) = sandbox_mode {
root.insert(
"sandbox_mode".to_string(),
TomlValue::String(sandbox_mode.to_string()),
);
}
if sandbox_mode != Some("workspace-write") {
return;
}
let mut workspace_write = toml::map::Map::new();
if let Some(paths) = sandbox
.get("additionalReadwritePaths")
.and_then(JsonValue::as_array)
{
let paths = paths
.iter()
.filter_map(JsonValue::as_str)
.filter(|path| Path::new(path).is_absolute())
.map(|path| TomlValue::String(path.to_string()))
.collect::<Vec<_>>();
if !paths.is_empty() {
workspace_write.insert("writable_roots".to_string(), TomlValue::Array(paths));
}
}
if sandbox.get("disableTmpWrite").and_then(JsonValue::as_bool) == Some(true) {
workspace_write.insert("exclude_slash_tmp".to_string(), TomlValue::Boolean(true));
workspace_write.insert(
"exclude_tmpdir_env_var".to_string(),
TomlValue::Boolean(true),
);
}
if sandbox
.get("networkPolicy")
.and_then(JsonValue::as_object)
.and_then(|network| network.get("default"))
.and_then(JsonValue::as_str)
== Some("allow")
{
workspace_write.insert("network_access".to_string(), TomlValue::Boolean(true));
}
if !workspace_write.is_empty() {
root.insert(
"sandbox_workspace_write".to_string(),
TomlValue::Table(workspace_write),
);
}
}
pub(super) fn detect_plugins(
context: &PluginDetectionContext<'_>,
) -> io::Result<Option<DetectedSourcePlugins>> {
let mut plugins = Vec::new();
for marketplace in cached_marketplace_plugins(context.external_agent_home)? {
let configured_marketplace = context
.configured_marketplace_plugins
.get(&marketplace.name);
let plugin_names = marketplace
.plugin_names
.into_iter()
.filter(|plugin_name| {
!context
.configured_plugin_ids
.contains(&format!("{plugin_name}@{}", marketplace.name))
&& configured_marketplace.is_none_or(|plugins| plugins.contains(plugin_name))
})
.collect::<Vec<_>>();
if !plugin_names.is_empty() {
plugins.push(PluginsMigration {
marketplace_name: marketplace.name,
plugin_names,
});
}
}
if plugins.is_empty() {
return Ok(None);
}
Ok(Some(DetectedSourcePlugins {
description: format!(
"Migrate cached plugins from {}",
context.external_agent_home.join("plugins/cache").display()
),
details: MigrationDetails {
plugins,
..Default::default()
},
}))
}
pub(super) fn marketplace_import_sources(
external_agent_home: &Path,
) -> io::Result<BTreeMap<String, MarketplaceImportSource>> {
Ok(cached_marketplace_plugins(external_agent_home)?
.into_iter()
.map(|marketplace| {
(
marketplace.name,
MarketplaceImportSource {
source: marketplace.source.display().to_string(),
ref_name: None,
},
)
})
.collect())
}
pub(super) fn import_source_commands(
source_commands: &Path,
target_skills: &Path,
) -> io::Result<Vec<String>> {
import_commands_with_profile(source_commands, target_skills, COMMAND_MIGRATION_PROFILE)
}
pub(super) fn count_missing_source_commands(
source_commands: &Path,
target_skills: &Path,
) -> io::Result<usize> {
count_missing_commands_with_profile(source_commands, target_skills, COMMAND_MIGRATION_PROFILE)
}
pub(super) fn missing_source_command_names(
source_commands: &Path,
target_skills: &Path,
) -> io::Result<Vec<String>> {
missing_command_names_with_profile(source_commands, target_skills, COMMAND_MIGRATION_PROFILE)
}
pub(super) fn import_source_subagents(
source_agents: &Path,
target_agents: &Path,
) -> io::Result<Vec<String>> {
import_subagents_with_rewrite_profile(source_agents, target_agents, REWRITE_PROFILE)
}
pub(super) fn source_hook_event_names(
source_dir: &Path,
target_hooks: &Path,
) -> io::Result<Vec<String>> {
hook_migration_event_names_cur(
source_dir,
&source_dir.join(HOOKS_CONFIG_FILE),
target_hooks,
REWRITE_PROFILE,
)
}
pub(super) fn import_source_hooks(source_dir: &Path, target_hooks: &Path) -> io::Result<bool> {
import_hooks_cur(
source_dir,
&source_dir.join(HOOKS_CONFIG_FILE),
target_hooks,
REWRITE_PROFILE,
)
}
pub(super) fn cached_marketplace_plugins(
external_agent_home: &Path,
) -> io::Result<Vec<CachedMarketplacePlugins>> {
let marketplaces_root = external_agent_home.join("plugins/marketplaces");
let cache_root = external_agent_home.join("plugins/cache");
if !marketplaces_root.is_dir() || !cache_root.is_dir() {
return Ok(Vec::new());
}
let mut marketplaces = Vec::new();
for entry in fs::read_dir(marketplaces_root)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let marketplace_root = entry.path();
let manifest_path = marketplace_root.join(PLUGIN_MARKETPLACE_MANIFEST);
if !manifest_path.is_file() {
continue;
}
let manifest = match fs::read_to_string(&manifest_path) {
Ok(manifest) => manifest,
Err(err) => {
tracing::warn!(
path = %manifest_path.display(),
error = %err,
"ignoring unreadable external marketplace manifest"
);
continue;
}
};
let manifest: JsonValue = match serde_json::from_str(&manifest) {
Ok(manifest) => manifest,
Err(err) => {
tracing::warn!(
path = %manifest_path.display(),
error = %err,
"ignoring invalid external marketplace manifest"
);
continue;
}
};
let Some(name) = manifest.get("name").and_then(JsonValue::as_str) else {
continue;
};
let available_plugins = manifest
.get("plugins")
.and_then(JsonValue::as_array)
.into_iter()
.flatten()
.filter_map(|plugin| plugin.get("name").and_then(JsonValue::as_str))
.collect::<BTreeSet<_>>();
let cache_marketplace = cache_root.join(entry.file_name());
if !cache_marketplace.is_dir() {
continue;
}
let mut plugin_names = fs::read_dir(cache_marketplace)?
.filter_map(Result::ok)
.filter_map(|plugin| {
plugin
.file_type()
.ok()
.filter(std::fs::FileType::is_dir)
.and_then(|_| plugin.file_name().into_string().ok())
})
.filter(|plugin_name| available_plugins.contains(plugin_name.as_str()))
.collect::<Vec<_>>();
plugin_names.sort();
if !plugin_names.is_empty() {
marketplaces.push(CachedMarketplacePlugins {
name: name.to_string(),
source: marketplace_root,
plugin_names,
});
}
}
marketplaces.sort_by(|left, right| left.name.cmp(&right.name));
Ok(marketplaces)
}
#[cfg(test)]
#[path = "source_cur_tests.rs"]
mod tests;
@@ -1,139 +0,0 @@
use codex_external_agent_migration::RewriteProfile;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
pub(super) fn display_source_paths(paths: &[PathBuf]) -> String {
paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
pub(super) fn copy_dir_recursive(
source: &Path,
target: &Path,
rewrite_profile: RewriteProfile,
) -> 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, rewrite_profile)?;
continue;
}
if file_type.is_file() {
if is_skill_md(&source_path) {
rewrite_and_copy_text_file(&source_path, &target_path, rewrite_profile)?;
} else {
fs::copy(source_path, target_path)?;
}
}
}
Ok(())
}
pub(super) fn rewrite_external_agent_terms(
content: &str,
rewrite_profile: RewriteProfile,
) -> String {
let mut rewritten = replace_case_insensitive_with_boundaries(
content,
rewrite_profile.doc_file_name(),
"AGENTS.md",
);
for from in rewrite_profile.term_variants() {
rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex");
}
for from in rewrite_profile.case_sensitive_term_variants() {
rewritten = replace_with_boundaries(&rewritten, from, "Codex");
}
rewritten
}
fn replace_with_boundaries(input: &str, needle: &str, replacement: &str) -> String {
if needle.is_empty() {
return input.to_string();
}
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) = input[search_start..].find(needle) {
let start = search_start + relative_pos;
let end = start + needle.len();
let boundary_before = start == 0 || !is_word_char(bytes[start - 1]);
let boundary_after = end == bytes.len() || !is_word_char(bytes[end]);
if boundary_before && boundary_after {
output.push_str(&input[last_emitted..start]);
output.push_str(replacement);
last_emitted = end;
}
search_start = end;
}
output.push_str(&input[last_emitted..]);
output
}
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,
rewrite_profile: RewriteProfile,
) -> io::Result<()> {
let source_contents = fs::read_to_string(source)?;
let rewritten = rewrite_external_agent_terms(&source_contents, rewrite_profile);
fs::write(target, 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_char(bytes[start - 1]);
let boundary_after = end == bytes.len() || !is_word_char(bytes[end]);
if boundary_before && boundary_after {
output.push_str(&input[last_emitted..start]);
output.push_str(replacement);
last_emitted = end;
}
search_start = end;
}
output.push_str(&input[last_emitted..]);
output
}
fn is_word_char(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,8 @@ use chrono::Utc;
use codex_arg0::Arg0DispatchPaths;
use codex_core::ThreadManager;
use codex_core::config::ConfigOverrides;
use codex_external_agent_migration::ExternalAgentConfigImportItemResult;
use codex_external_agent_migration::record_import_error;
use codex_external_agent_migration::sessions::CompletedExternalAgentSessionImport;
use codex_external_agent_migration::sessions::ExternalAgentSessionMigration;
use codex_external_agent_migration::sessions::ImportedExternalAgentSession;
@@ -30,8 +32,6 @@ use codex_thread_store::UpdateThreadMetadataParams;
use futures::StreamExt;
use tokio::sync::Semaphore;
use super::service::ExternalAgentConfigImportItemResult;
use super::service::record_import_error;
use crate::config_manager::ConfigManager;
const SESSION_IMPORT_CONCURRENCY: usize = 5;
@@ -14,16 +14,26 @@ workspace = true
[dependencies]
chrono = { workspace = true }
codex-analytics = { workspace = true }
codex-config = { workspace = true }
codex-core = { workspace = true }
codex-core-plugins = { workspace = true }
codex-hooks = { workspace = true }
codex-memories-write = { workspace = true }
codex-otel = { workspace = true }
codex-plugin = { workspace = true }
codex-protocol = { workspace = true }
codex-rollout = { workspace = true }
codex-utils-output-truncation = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
sha2 = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
codex-app-server-protocol = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
@@ -0,0 +1,101 @@
use std::fs;
use std::io;
use std::path::Path;
use toml::Value as TomlValue;
use crate::utils::invalid_data_error;
pub(super) 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",
)),
}
}
pub(super) 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)
}
pub(super) 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()))
}
pub(super) 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()
}
pub(super) 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,
}
}
@@ -0,0 +1,33 @@
use crate::discover_external_memory_files;
use crate::memory_import::projects_needing_import;
use crate::memory_import::resources_root;
use crate::model::ExternalAgentConfigMigrationItem;
use crate::model::ExternalAgentConfigMigrationItemType;
use crate::model::MigrationDetails;
use std::io;
use std::path::Path;
pub(super) fn detect(
codex_home: &Path,
external_agent_home: &Path,
) -> io::Result<Option<ExternalAgentConfigMigrationItem>> {
let memory_files = discover_external_memory_files(external_agent_home)?;
let memory = projects_needing_import(codex_home, &memory_files)?;
if memory.is_empty() {
return Ok(None);
}
Ok(Some(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Memory,
description: format!(
"Import memory files from {} to {}",
external_agent_home.join("projects").display(),
resources_root(codex_home).display()
),
cwd: None,
details: Some(MigrationDetails {
memory: memory.into_iter().collect(),
..Default::default()
}),
}))
}
@@ -0,0 +1,407 @@
mod memory;
pub(crate) mod plugins;
pub(crate) mod sessions;
use crate::config_values::is_empty_toml_table;
use crate::config_values::merge_missing_mcp_servers;
use crate::config_values::merge_missing_toml_values;
use crate::config_values::migrated_mcp_server_names;
use crate::count_missing_subagents;
use crate::migration_source::InstructionSourceGroup;
use crate::migration_source::PluginDetectionContext;
use crate::missing_subagent_names;
use crate::model::ExternalAgentConfigDetectOptions;
use crate::model::ExternalAgentConfigMigrationItem;
use crate::model::ExternalAgentConfigMigrationItemType;
use crate::model::MigrationDetails;
use crate::reporting::emit_migration_metric;
use crate::scope::MigrationScope;
use crate::service::ExternalAgentConfigService;
use crate::service::configured_marketplace_plugins;
use crate::service::missing_subdirectory_names;
use crate::service::named_migrations;
use crate::utils::display_source_paths;
use crate::utils::invalid_data_error;
use crate::utils::is_missing_or_empty_text_file;
use codex_config::types::PluginConfig;
use codex_core::config::ConfigBuilder;
use codex_core_plugins::PluginsManager;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs;
use std::io;
use toml::Value as TomlValue;
const EXTERNAL_AGENT_CONFIG_DETECT_METRIC: &str = "codex.external_agent_config.detect";
impl ExternalAgentConfigService {
pub async fn detect(
&self,
params: ExternalAgentConfigDetectOptions,
) -> io::Result<Vec<ExternalAgentConfigMigrationItem>> {
let mut items = Vec::new();
if params.include_home {
self.detect_migrations(&MigrationScope::home(), &mut items)
.await?;
}
for cwd in params.cwds.as_deref().unwrap_or(&[]) {
let Some(scope) = MigrationScope::from_cwd(Some(cwd))? else {
continue;
};
if scope.is_home() {
continue;
}
self.detect_migrations(&scope, &mut items).await?;
}
if params.include_home
&& params.include_memory
&& self.source.supports_memory()
&& let Some(item) = memory::detect(&self.codex_home, &self.external_agent_home)?
{
items.push(item);
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
ExternalAgentConfigMigrationItemType::Memory,
/*skills_count*/ None,
);
}
Ok(items)
}
async fn detect_migrations(
&self,
scope: &MigrationScope,
items: &mut Vec<ExternalAgentConfigMigrationItem>,
) -> io::Result<()> {
let repo_root = scope.repo_root();
let cwd = scope.cwd();
let source_settings = self.source_settings(scope);
let settings = self.effective_source_settings(scope)?;
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 = self.source.build_config(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 mcp_source_path = self
.source
.mcp_source_path(self.source_root(scope), self.source_config_dir(scope));
let migrated_mcp = self.build_mcp_config(scope, settings.clone())?;
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 {}",
mcp_source_path.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 = self.source_config_dir(scope);
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 = self
.source
.hook_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(self.source.config_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 skill_names = missing_subdirectory_names(&source_skills, &target_skills)?;
let skills_count = skill_names.len();
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: Some(MigrationDetails {
skills: named_migrations(skill_names),
..Default::default()
}),
});
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 = self
.source
.count_missing_commands(&source_commands, &target_command_skills)?;
if commands_count > 0 {
let command_names = self
.source
.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 instruction_source_groups = if let Some(repo_root) = repo_root {
self.repo_agents_md_source_groups(repo_root)?
} else {
let sources = self.home_agents_md_sources()?;
(!sources.is_empty())
.then(|| InstructionSourceGroup {
scope: self.codex_home.clone(),
sources,
})
.into_iter()
.collect()
};
for group in instruction_source_groups {
let target_agents_md = group.scope.join("AGENTS.md");
if !is_missing_or_empty_text_file(&target_agents_md)? {
continue;
}
let item_cwd = repo_root.is_some().then(|| group.scope.clone());
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Migrate {} to {}",
display_source_paths(&group.sources),
target_agents_md.display()
),
cwd: item_cwd,
details: None,
});
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
ExternalAgentConfigMigrationItemType::AgentsMd,
/*skills_count*/ None,
);
}
if self.source.supports_plugin_migration(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()),
)?;
let source_root = repo_root.unwrap_or(self.external_agent_home.as_path());
if let Some(detected) =
self.source.plugin_migration(PluginDetectionContext {
external_agent_home: self.external_agent_home.as_path(),
source_settings: source_settings.as_path(),
source_root,
repo_root,
settings: settings.as_ref(),
configured_plugin_ids: &configured_plugin_ids,
configured_marketplace_plugins: &configured_marketplace_plugins,
})?
{
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
ExternalAgentConfigMigrationItemType::Plugins,
/*skills_count*/ None,
);
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: detected.description,
cwd: cwd.clone(),
details: Some(detected.details),
});
}
}
Err(err) => {
tracing::warn!(
error = %err,
settings_path = %source_settings.display(),
"skipping external agent plugin migration detection because config load failed"
);
}
}
}
if scope.is_home() {
let sessions = self
.source
.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(())
}
}
@@ -0,0 +1,76 @@
use crate::migration_source::DetectedSourcePlugins;
use crate::migration_source::PluginDetectionContext;
use crate::model::MigrationDetails;
use crate::model::PluginsMigration;
use crate::source_cla;
use crate::source_cur;
use serde_json::Value as JsonValue;
use std::io;
pub(crate) fn detect_cla_plugins(
context: &PluginDetectionContext<'_>,
) -> Option<DetectedSourcePlugins> {
let settings = context.settings?;
let import_sources = source_cla::marketplace_import_sources(
settings,
context.external_agent_home,
context.source_root,
);
let details = source_cla::extract_plugin_migration_details(
settings,
&import_sources,
context.configured_plugin_ids,
context.configured_marketplace_plugins,
)?;
Some(DetectedSourcePlugins {
description: format!(
"Migrate enabled plugins from {}",
context.source_settings.display()
),
details,
})
}
pub(crate) fn can_detect_cla_plugins(settings: Option<&JsonValue>) -> bool {
settings.is_some()
}
pub(crate) fn detect_cur_plugins(
context: &PluginDetectionContext<'_>,
) -> io::Result<Option<DetectedSourcePlugins>> {
let mut plugins = Vec::new();
for marketplace in source_cur::cached_marketplace_plugins(context.external_agent_home)? {
let configured_marketplace = context
.configured_marketplace_plugins
.get(&marketplace.name);
let plugin_names = marketplace
.plugin_names
.into_iter()
.filter(|plugin_name| {
!context
.configured_plugin_ids
.contains(&format!("{plugin_name}@{}", marketplace.name))
&& configured_marketplace.is_none_or(|plugins| plugins.contains(plugin_name))
})
.collect::<Vec<_>>();
if !plugin_names.is_empty() {
plugins.push(PluginsMigration {
marketplace_name: marketplace.name,
plugin_names,
});
}
}
if plugins.is_empty() {
return Ok(None);
}
Ok(Some(DetectedSourcePlugins {
description: format!(
"Migrate cached plugins from {}",
context.external_agent_home.join("plugins/cache").display()
),
details: MigrationDetails {
plugins,
..Default::default()
},
}))
}
@@ -1,17 +1,11 @@
use super::ExternalAgentSessionMigration;
use super::ledger::load_import_ledger;
use super::ledger::save_import_ledger;
use super::now_unix_seconds;
use super::summarize_session;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
#[cfg(test)]
use super::common::SESSION_IMPORT_MAX_COUNT;
use super::common::SessionFileCandidate;
use super::common::detect_recent_sessions;
use crate::sessions::ExternalAgentSessionMigration;
use std::fs;
use std::io;
use std::path::Path;
use std::time::Duration;
const SESSION_IMPORT_MAX_COUNT: usize = 50;
const SESSION_IMPORT_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
pub fn detect_recent_cla_sessions(
external_agent_home: &Path,
@@ -22,10 +16,7 @@ pub fn detect_recent_cla_sessions(
return Ok(Vec::new());
}
let now = now_unix_seconds();
let mut ledger = load_import_ledger(codex_home)?;
let source_states = ledger.source_states();
let mut file_candidates = BinaryHeap::with_capacity(SESSION_IMPORT_MAX_COUNT + 1);
let mut candidates = Vec::new();
for project_entry in fs::read_dir(projects_root)? {
let Ok(project_entry) = project_entry else {
continue;
@@ -45,78 +36,25 @@ pub fn detect_recent_cla_sessions(
if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
continue;
}
let Ok(metadata) = entry.metadata() else {
continue;
};
let Ok(modified_at) = metadata.modified() else {
continue;
};
let Ok(modified_at) = modified_at.duration_since(std::time::UNIX_EPOCH) else {
continue;
};
if (modified_at.as_secs() as i64)
< now.saturating_sub(SESSION_IMPORT_MAX_AGE.as_secs() as i64)
{
continue;
}
let Ok(modified_at_nanos) = i64::try_from(modified_at.as_nanos()) else {
continue;
};
let Ok(source_path) = fs::canonicalize(&path) else {
continue;
};
if let Some(state) = source_states.get(source_path.as_path())
&& (state.source_modified_at == Some(modified_at_nanos)
|| state.source_modified_at.is_none()
&& modified_at.as_secs() as i64 <= state.imported_at)
{
continue;
}
file_candidates.push((Reverse(modified_at_nanos), path));
if file_candidates.len() > SESSION_IMPORT_MAX_COUNT {
file_candidates.pop();
}
candidates.push(SessionFileCandidate {
path,
fallback_cwd: None,
});
}
}
drop(source_states);
let file_candidates = file_candidates.into_sorted_vec();
let mut migrations = Vec::new();
let mut ledger_changed = false;
for (modified_at, path) in file_candidates {
match ledger.refresh_current_source(&path, modified_at.0) {
Ok(false) => {}
Ok(true) => {
ledger_changed = true;
continue;
}
Err(_) => continue,
}
let Ok(Some(summary)) = summarize_session(&path) else {
continue;
};
let migration = summary.migration;
if !migration.cwd.is_dir() {
continue;
}
migrations.push(migration);
}
if ledger_changed {
save_import_ledger(codex_home, &ledger)?;
}
Ok(migrations)
detect_recent_sessions(codex_home, candidates, /*require_existing_cwd*/ true)
}
#[cfg(test)]
mod tests {
use super::super::ledger::record_imported_session;
use super::*;
use crate::sessions::ledger::record_imported_session;
use codex_protocol::ThreadId;
use serde_json::Value as JsonValue;
use std::fs::FileTimes;
use std::fs::OpenOptions;
use std::path::Path;
use std::time::Duration;
use std::time::SystemTime;
use tempfile::TempDir;
@@ -0,0 +1,95 @@
use crate::sessions::ExternalAgentSessionMigration;
use crate::sessions::ledger::load_import_ledger;
use crate::sessions::ledger::save_import_ledger;
use crate::sessions::now_unix_seconds;
use crate::sessions::records::summarize_session_with_cwd;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
pub(super) const SESSION_IMPORT_MAX_COUNT: usize = 50;
const SESSION_IMPORT_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
pub(super) struct SessionFileCandidate {
pub path: PathBuf,
pub fallback_cwd: Option<PathBuf>,
}
pub(super) fn detect_recent_sessions(
codex_home: &Path,
candidates: impl IntoIterator<Item = SessionFileCandidate>,
require_existing_cwd: bool,
) -> io::Result<Vec<ExternalAgentSessionMigration>> {
let now = now_unix_seconds();
let mut ledger = load_import_ledger(codex_home)?;
let source_states = ledger.source_states();
let mut recent = BinaryHeap::with_capacity(SESSION_IMPORT_MAX_COUNT + 1);
for candidate in candidates {
let Ok(metadata) = fs::metadata(&candidate.path) else {
continue;
};
let Ok(modified_at) = metadata.modified() else {
continue;
};
let Ok(modified_at) = modified_at.duration_since(std::time::UNIX_EPOCH) else {
continue;
};
if (modified_at.as_secs() as i64)
< now.saturating_sub(SESSION_IMPORT_MAX_AGE.as_secs() as i64)
{
continue;
}
let Ok(modified_at_nanos) = i64::try_from(modified_at.as_nanos()) else {
continue;
};
let Ok(source_path) = fs::canonicalize(&candidate.path) else {
continue;
};
if let Some(state) = source_states.get(source_path.as_path())
&& (state.source_modified_at == Some(modified_at_nanos)
|| state.source_modified_at.is_none()
&& modified_at.as_secs() as i64 <= state.imported_at)
{
continue;
}
recent.push((
Reverse(modified_at_nanos),
candidate.path,
candidate.fallback_cwd,
));
if recent.len() > SESSION_IMPORT_MAX_COUNT {
recent.pop();
}
}
drop(source_states);
let mut migrations = Vec::new();
let mut ledger_changed = false;
for (modified_at, path, fallback_cwd) in recent.into_sorted_vec() {
match ledger.refresh_current_source(&path, modified_at.0) {
Ok(false) => {}
Ok(true) => {
ledger_changed = true;
continue;
}
Err(_) => continue,
}
let Ok(Some(summary)) = summarize_session_with_cwd(&path, fallback_cwd.as_deref()) else {
continue;
};
if require_existing_cwd && !summary.migration.cwd.is_dir() {
continue;
}
migrations.push(summary.migration);
}
if ledger_changed {
save_import_ledger(codex_home, &ledger)?;
}
Ok(migrations)
}
@@ -71,7 +71,8 @@ pub fn detect_imported_cla_session_connectors(
if !attributed_server_ids.contains(&uuid) {
continue;
}
let Some(name) = super::normalized_connector_display_name(server.name.as_deref())
let Some(name) =
crate::sessions::normalized_connector_display_name(server.name.as_deref())
else {
continue;
};
@@ -1,18 +1,12 @@
use super::ExternalAgentSessionMigration;
use super::ledger::load_import_ledger;
use super::ledger::save_import_ledger;
use super::now_unix_seconds;
use super::records::summarize_session_with_cwd;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
#[cfg(test)]
use super::common::SESSION_IMPORT_MAX_COUNT;
use super::common::SessionFileCandidate;
use super::common::detect_recent_sessions;
use crate::sessions::ExternalAgentSessionMigration;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
const SESSION_IMPORT_MAX_COUNT: usize = 50;
const SESSION_IMPORT_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
pub fn detect_recent_cur_sessions(
external_agent_home: &Path,
@@ -23,10 +17,7 @@ pub fn detect_recent_cur_sessions(
return Ok(Vec::new());
}
let now = now_unix_seconds();
let mut ledger = load_import_ledger(codex_home)?;
let source_states = ledger.source_states();
let mut candidates = BinaryHeap::with_capacity(SESSION_IMPORT_MAX_COUNT + 1);
let mut candidates = Vec::new();
for project_entry in fs::read_dir(projects_root)? {
let Ok(project_entry) = project_entry else {
continue;
@@ -37,61 +28,13 @@ pub fn detect_recent_cur_sessions(
}
let fallback_cwd = cur_project_cwd(&project_storage);
for path in cur_transcript_files(&project_storage.join("agent-transcripts")) {
let Ok(metadata) = fs::metadata(&path) else {
continue;
};
let Ok(modified_at) = metadata.modified() else {
continue;
};
let Ok(modified_at) = modified_at.duration_since(std::time::UNIX_EPOCH) else {
continue;
};
if (modified_at.as_secs() as i64)
< now.saturating_sub(SESSION_IMPORT_MAX_AGE.as_secs() as i64)
{
continue;
}
let Ok(modified_at_nanos) = i64::try_from(modified_at.as_nanos()) else {
continue;
};
let Ok(source_path) = fs::canonicalize(&path) else {
continue;
};
if let Some(state) = source_states.get(source_path.as_path())
&& (state.source_modified_at == Some(modified_at_nanos)
|| state.source_modified_at.is_none()
&& modified_at.as_secs() as i64 <= state.imported_at)
{
continue;
}
candidates.push((Reverse(modified_at_nanos), path, fallback_cwd.clone()));
if candidates.len() > SESSION_IMPORT_MAX_COUNT {
candidates.pop();
}
candidates.push(SessionFileCandidate {
path,
fallback_cwd: fallback_cwd.clone(),
});
}
}
drop(source_states);
let mut migrations = Vec::new();
let mut ledger_changed = false;
for (modified_at, path, fallback_cwd) in candidates.into_sorted_vec() {
match ledger.refresh_current_source(&path, modified_at.0) {
Ok(false) => {}
Ok(true) => {
ledger_changed = true;
continue;
}
Err(_) => continue,
}
let Ok(Some(summary)) = summarize_session_with_cwd(&path, fallback_cwd.as_deref()) else {
continue;
};
migrations.push(summary.migration);
}
if ledger_changed {
save_import_ledger(codex_home, &ledger)?;
}
Ok(migrations)
detect_recent_sessions(codex_home, candidates, /*require_existing_cwd*/ false)
}
fn cur_transcript_files(transcripts_root: &Path) -> Vec<PathBuf> {
@@ -207,5 +150,5 @@ fn unique_path(mut matches: Vec<PathBuf>) -> Option<PathBuf> {
}
#[cfg(test)]
#[path = "detect_cur_tests.rs"]
#[path = "cur_tests.rs"]
mod tests;
@@ -3,6 +3,7 @@ use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use std::fs::FileTimes;
use std::fs::OpenOptions;
use std::time::Duration;
use std::time::SystemTime;
use tempfile::TempDir;
@@ -191,15 +192,19 @@ fn detects_cur_sessions_in_batches_and_redetects_modified_imports() {
assert_eq!(sessions, expected);
for session in &sessions {
super::super::ledger::record_imported_session(root.path(), &session.path, ThreadId::new())
.expect("record import");
crate::sessions::ledger::record_imported_session(
root.path(),
&session.path,
ThreadId::new(),
)
.expect("record import");
}
assert_eq!(
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions"),
vec![oldest_session.clone()]
);
super::super::ledger::record_imported_session(
crate::sessions::ledger::record_imported_session(
root.path(),
&oldest_session.path,
ThreadId::new(),
@@ -0,0 +1,9 @@
mod cla;
mod common;
mod connectors_cla;
mod cur;
pub use cla::detect_recent_cla_sessions;
pub use connectors_cla::ImportedSessionConnectorAttribution;
pub use connectors_cla::detect_imported_cla_session_connectors;
pub use cur::detect_recent_cur_sessions;
@@ -2,7 +2,6 @@ use super::RewriteProfile;
use super::external_agent_config_dir;
use super::invalid_data_error;
use super::json_u64;
use super::rewrite_external_agent_terms;
use super::rewrite_hook_command_for_source;
use super::write_hook_migration;
use codex_hooks::HOOK_EVENT_NAMES;
@@ -193,10 +192,7 @@ pub(super) fn append_convertible_hook_groups_cla(
{
command_payload.insert(
"statusMessage".to_string(),
JsonValue::String(rewrite_external_agent_terms(
status_message,
rewrite_profile,
)),
JsonValue::String(rewrite_profile.rewrite(status_message)),
);
}
hook_commands.push(JsonValue::Object(command_payload));
@@ -0,0 +1,294 @@
use crate::invalid_data_error;
use serde_json::Value as JsonValue;
use std::fs;
use std::io;
use std::path::Path;
pub(super) const SOURCE_EXTERNAL_AGENT_NAME: &str = "claude";
pub(super) const EXTERNAL_AGENT_HOOKS_SUBDIR: &str = "hooks";
pub(super) const EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR: &str = "hooks";
pub(crate) fn write_hook_migration(
source_external_agent_dir: &Path,
target_hooks: &Path,
migration: serde_json::Map<String, JsonValue>,
) -> io::Result<bool> {
if migration.is_empty() || !is_missing_or_empty_text_file(target_hooks)? {
return Ok(false);
}
let Some(parent) = target_hooks.parent() else {
return Err(invalid_data_error("hooks target path has no parent"));
};
fs::create_dir_all(parent)?;
copy_hook_scripts(source_external_agent_dir, parent)?;
let mut payload = serde_json::Map::new();
payload.insert("hooks".to_string(), JsonValue::Object(migration));
let rendered = serde_json::to_string_pretty(&JsonValue::Object(payload))
.map_err(|err| invalid_data_error(format!("failed to serialize hooks.json: {err}")))?;
fs::write(target_hooks, format!("{rendered}\n"))?;
Ok(true)
}
pub(crate) fn rewrite_hook_command_for_source(
command: &str,
target_config_dir: Option<&Path>,
source_external_agent_dir: &Path,
) -> String {
let Some(target_config_dir) = target_config_dir else {
return command.to_string();
};
if looks_like_windows_hook_command(command) {
return command.to_string();
}
let target_hooks_dir = target_config_dir.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR);
let source_config_dir = source_external_agent_dir
.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
.unwrap_or_else(external_agent_config_dir);
let source_hooks_path = format!("{source_config_dir}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/");
let command = replace_quoted_hook_paths(command, '\'', &source_hooks_path, &target_hooks_dir);
let command = replace_quoted_hook_paths(&command, '"', &source_hooks_path, &target_hooks_dir);
replace_unquoted_hook_paths(&command, &source_hooks_path, &target_hooks_dir)
}
fn replace_quoted_hook_paths(
command: &str,
quote: char,
source_hooks_path: &str,
target_hooks_dir: &Path,
) -> String {
let mut rewritten = command.to_string();
let mut search_start = 0usize;
while let Some(relative_start) = rewritten[search_start..].find(quote) {
let start = search_start + relative_start;
let content_start = start + quote.len_utf8();
let Some(relative_end) = rewritten[content_start..].find(quote) else {
break;
};
let end = content_start + relative_end;
let content = &rewritten[content_start..end];
if let Some(source_hooks_start) = content.find(source_hooks_path) {
let suffix_start = source_hooks_start + source_hooks_path.len();
let suffix = &content[suffix_start..];
let Some(replacement) =
target_hook_path_replacement(target_hooks_dir, content, source_hooks_start, suffix)
else {
search_start = end + quote.len_utf8();
continue;
};
rewritten.replace_range(start..end + quote.len_utf8(), &replacement);
search_start = start + replacement.len();
} else {
search_start = end + quote.len_utf8();
}
}
rewritten
}
fn replace_unquoted_hook_paths(
command: &str,
source_hooks_path: &str,
target_hooks_dir: &Path,
) -> String {
let mut rewritten = command.to_string();
let mut search_start = 0usize;
while let Some(source_hooks_start) =
find_unquoted_source_hook_path(&rewritten, source_hooks_path, search_start)
{
let path_start = shell_path_start(&rewritten, source_hooks_start);
let path_end = shell_path_end(&rewritten, source_hooks_start + source_hooks_path.len());
if is_assignment_value_start(&rewritten, path_start) {
search_start = source_hooks_start + source_hooks_path.len();
continue;
}
let path = rewritten[path_start..path_end].to_string();
let suffix = rewritten[source_hooks_start + source_hooks_path.len()..path_end].to_string();
if let Some(replacement) = target_hook_path_replacement(
target_hooks_dir,
&path,
source_hooks_start - path_start,
&suffix,
) {
rewritten.replace_range(path_start..path_end, &replacement);
search_start = path_start + replacement.len();
} else {
search_start = source_hooks_start + source_hooks_path.len();
}
}
rewritten
}
fn find_unquoted_source_hook_path(
command: &str,
source_hooks_path: &str,
start: usize,
) -> Option<usize> {
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut escaped = false;
for (offset, ch) in command[start..].char_indices() {
let index = start + offset;
if escaped {
escaped = false;
continue;
}
if !in_single_quote && ch == '\\' {
escaped = true;
continue;
}
match ch {
'\'' if !in_double_quote => {
in_single_quote = !in_single_quote;
}
'"' if !in_single_quote => {
in_double_quote = !in_double_quote;
}
_ if !in_single_quote
&& !in_double_quote
&& command[index..].starts_with(source_hooks_path) =>
{
return Some(index);
}
_ => {}
}
}
None
}
fn is_pure_shell_path_content(content: &str, source_hooks_start: usize) -> bool {
let prefix = &content[..source_hooks_start];
(prefix.is_empty() || prefix == "./" || prefix.ends_with('/'))
&& !prefix.chars().any(is_shell_path_boundary)
}
fn shell_path_start(command: &str, end: usize) -> usize {
command[..end]
.char_indices()
.filter_map(|(index, ch)| is_shell_path_boundary(ch).then_some(index + ch.len_utf8()))
.next_back()
.unwrap_or(0)
}
fn shell_path_end(command: &str, start: usize) -> usize {
let mut escaped = false;
for (offset, ch) in command[start..].char_indices() {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if is_shell_path_boundary(ch) {
return start + offset;
}
}
command.len()
}
fn is_shell_path_boundary(ch: char) -> bool {
ch.is_whitespace() || matches!(ch, '=' | ';' | '|' | '&' | '<' | '>' | '(' | ')')
}
fn is_assignment_value_start(command: &str, path_start: usize) -> bool {
command[..path_start]
.chars()
.next_back()
.is_some_and(|ch| ch == '=')
}
fn target_hook_path_replacement(
target_hooks_dir: &Path,
path: &str,
source_hooks_start: usize,
suffix: &str,
) -> Option<String> {
if !is_pure_shell_path_content(path, source_hooks_start) || !is_static_hook_path_suffix(suffix)
{
return None;
}
Some(shell_single_quote(
target_hooks_dir.join(suffix).to_string_lossy().as_ref(),
))
}
fn is_static_hook_path_suffix(suffix: &str) -> bool {
!suffix.is_empty()
&& !suffix
.chars()
.any(|ch| matches!(ch, '\\' | '$' | '`' | '*' | '?' | '[' | '{' | '}'))
}
fn looks_like_windows_hook_command(command: &str) -> bool {
let source_hooks_backslash_path = format!(
r"{}\{EXTERNAL_AGENT_HOOKS_SUBDIR}\",
external_agent_config_dir()
);
let project_dir_env_var = external_agent_project_dir_env_var();
command.contains(&source_hooks_backslash_path)
|| command.contains(&format!("%{project_dir_env_var}%"))
|| command.contains(&format!("$env:{project_dir_env_var}"))
}
pub(super) fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
pub(super) fn copy_hook_scripts(
source_external_agent_dir: &Path,
target_config_dir: &Path,
) -> io::Result<()> {
let source_hooks = source_external_agent_dir.join(EXTERNAL_AGENT_HOOKS_SUBDIR);
if !source_hooks.is_dir() {
return Ok(());
}
let target_hooks = target_config_dir.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR);
copy_dir_recursive_skip_existing(&source_hooks, &target_hooks)
}
fn copy_dir_recursive_skip_existing(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_skip_existing(&source_path, &target_path)?;
} else if file_type.is_file() && !target_path.exists() {
fs::copy(source_path, target_path)?;
}
}
Ok(())
}
pub(crate) fn json_u64(value: &JsonValue) -> Option<u64> {
if value.is_boolean() || value.is_null() {
return None;
}
value.as_u64().or_else(|| value.as_str()?.parse().ok())
}
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())
}
pub(crate) fn external_agent_config_dir() -> String {
format!(".{SOURCE_EXTERNAL_AGENT_NAME}")
}
pub(crate) fn external_agent_project_dir_env_var() -> String {
format!(
"{}_PROJECT_DIR",
SOURCE_EXTERNAL_AGENT_NAME.to_ascii_uppercase()
)
}
@@ -1,7 +1,6 @@
use super::RewriteProfile;
use super::invalid_data_error;
use super::json_u64;
use super::rewrite_external_agent_terms;
use super::rewrite_hook_command_for_source;
use super::write_hook_migration;
use codex_hooks::HOOK_EVENT_NAMES_WITH_MATCHERS;
@@ -123,10 +122,7 @@ fn hook_migration_cur(
if let Some(status_message) = handler.get("statusMessage").and_then(JsonValue::as_str) {
command_payload.insert(
"statusMessage".to_string(),
JsonValue::String(rewrite_external_agent_terms(
status_message,
rewrite_profile,
)),
JsonValue::String(rewrite_profile.rewrite(status_message)),
);
}
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,10 @@ use super::hooks_cla::hook_migration_cla;
use super::hooks_cla::rewrite_hook_command_cla;
use super::*;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
const TEST_REWRITE_PROFILE: RewriteProfile = RewriteProfile::new(
"CLAUDE.md",
@@ -0,0 +1,372 @@
use crate::invalid_data_error;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fs;
use std::io;
use std::path::Path;
use toml::Value as TomlValue;
pub(super) const EXTERNAL_AGENT_MCP_CONFIG_FILE: &str = ".mcp.json";
const EXTERNAL_AGENT_PROJECT_CONFIG_FILE: &str = ".claude.json";
pub fn build_mcp_config_from_external(
source_root: &Path,
external_agent_home: Option<&Path>,
settings: Option<&JsonValue>,
) -> io::Result<TomlValue> {
let mcp_servers = read_external_mcp_servers(source_root, external_agent_home)?;
build_mcp_config(mcp_servers, settings)
}
pub fn build_mcp_config_from_json_file(source_file: &Path) -> io::Result<TomlValue> {
if !source_file.is_file() {
return Ok(TomlValue::Table(Default::default()));
}
let raw = fs::read_to_string(source_file)?;
let parsed: JsonValue = serde_json::from_str(&raw)
.map_err(|err| invalid_data_error(format!("invalid MCP config: {err}")))?;
let mut mcp_servers = BTreeMap::new();
append_mcp_servers_from_value(&parsed, &mut mcp_servers, McpServerMerge::Overwrite);
build_mcp_config(mcp_servers, /*settings*/ None)
}
fn build_mcp_config(
mcp_servers: BTreeMap<String, JsonValue>,
settings: Option<&JsonValue>,
) -> io::Result<TomlValue> {
if mcp_servers.is_empty() {
return Ok(TomlValue::Table(Default::default()));
}
let enabled_servers = settings
.and_then(|settings| settings.get("enabledMcpjsonServers"))
.map(json_string_vec)
.unwrap_or_default();
let disabled_servers = settings
.and_then(|settings| settings.get("disabledMcpjsonServers"))
.map(json_string_vec)
.unwrap_or_default()
.into_iter()
.collect::<BTreeSet<_>>();
let mut servers = toml::map::Map::new();
for (server_name, server_config) in mcp_servers {
if let Some(server) = mcp_server_toml_table(
&server_name,
server_config.as_object(),
&enabled_servers,
&disabled_servers,
) {
servers.insert(server_name.clone(), TomlValue::Table(server));
}
}
if servers.is_empty() {
return Ok(TomlValue::Table(Default::default()));
}
let mut root = toml::map::Map::new();
root.insert("mcp_servers".to_string(), TomlValue::Table(servers));
Ok(TomlValue::Table(root))
}
fn read_external_mcp_servers(
source_root: &Path,
external_agent_home: Option<&Path>,
) -> io::Result<BTreeMap<String, JsonValue>> {
let mut servers = BTreeMap::new();
let project_config_file = external_agent_project_config_file();
for relative_path in [
EXTERNAL_AGENT_MCP_CONFIG_FILE.to_string(),
project_config_file.to_string(),
] {
let source_file = source_root.join(&relative_path);
if !source_file.is_file() {
continue;
}
let raw = fs::read_to_string(&source_file)?;
let parsed: JsonValue = serde_json::from_str(&raw)
.map_err(|err| invalid_data_error(format!("invalid MCP config: {err}")))?;
append_mcp_servers_from_value(&parsed, &mut servers, McpServerMerge::Overwrite);
if relative_path == project_config_file
&& let Some(projects) = parsed.get("projects").and_then(JsonValue::as_object)
{
for (project_path, project_config) in projects {
if project_path_matches_source_root(project_path, source_root) {
append_mcp_servers_from_value(
project_config,
&mut servers,
McpServerMerge::Overwrite,
);
}
}
}
}
if let Some(external_agent_root) = external_agent_home.and_then(Path::parent)
&& external_agent_root != source_root
{
append_external_agent_project_mcp_servers(
&external_agent_root.join(external_agent_project_config_file()),
source_root,
&mut servers,
)?;
}
Ok(servers)
}
fn append_external_agent_project_mcp_servers(
source_file: &Path,
source_root: &Path,
servers: &mut BTreeMap<String, JsonValue>,
) -> io::Result<()> {
if !source_file.is_file() {
return Ok(());
}
let raw = fs::read_to_string(source_file)?;
let parsed: JsonValue = serde_json::from_str(&raw)
.map_err(|err| invalid_data_error(format!("invalid MCP config: {err}")))?;
let Some(projects) = parsed.get("projects").and_then(JsonValue::as_object) else {
return Ok(());
};
for (project_path, project_config) in projects {
if project_path_matches_source_root(project_path, source_root) {
append_mcp_servers_from_value(
project_config,
servers,
McpServerMerge::PreserveExisting,
);
}
}
Ok(())
}
#[derive(Clone, Copy)]
enum McpServerMerge {
Overwrite,
PreserveExisting,
}
fn append_mcp_servers_from_value(
value: &JsonValue,
servers: &mut BTreeMap<String, JsonValue>,
merge: McpServerMerge,
) {
let Some(mcp_servers) = value.get("mcpServers").and_then(JsonValue::as_object) else {
return;
};
for (server_name, server_config) in mcp_servers {
match merge {
McpServerMerge::Overwrite => {
servers.insert(server_name.clone(), server_config.clone());
}
McpServerMerge::PreserveExisting => {
servers
.entry(server_name.clone())
.or_insert_with(|| server_config.clone());
}
}
}
}
fn project_path_matches_source_root(project_path: &str, source_root: &Path) -> bool {
let project_path = Path::new(project_path);
if project_path == source_root {
return true;
}
let Ok(project_path) = project_path.canonicalize() else {
return false;
};
source_root
.canonicalize()
.is_ok_and(|source_root| source_root == project_path)
}
fn mcp_server_toml_table(
server_name: &str,
server_config: Option<&serde_json::Map<String, JsonValue>>,
enabled_servers: &[String],
disabled_servers: &BTreeSet<String>,
) -> Option<toml::map::Map<String, TomlValue>> {
let mut table = toml::map::Map::new();
let server_config = server_config?;
let transport_type = server_config.get("type").and_then(JsonValue::as_str);
if mcp_server_is_disabled(
server_name,
server_config,
enabled_servers,
disabled_servers,
) {
return None;
}
if let Some(command) = server_config.get("command").and_then(json_string) {
if !matches!(transport_type, None | Some("stdio")) {
return None;
}
if contains_env_placeholder(&command) {
return None;
}
table.insert("command".to_string(), TomlValue::String(command));
if let Some(args) = server_config.get("args") {
let args = json_string_vec(args);
if args.iter().any(|arg| contains_env_placeholder(arg)) {
return None;
}
let args = args.into_iter().map(TomlValue::String).collect::<Vec<_>>();
if !args.is_empty() {
table.insert("args".to_string(), TomlValue::Array(args));
}
}
if let Some(env) = server_config.get("env").and_then(JsonValue::as_object) {
append_env_config(&mut table, env)?;
}
} else if let Some(url) = server_config.get("url").and_then(json_string) {
if !matches!(
transport_type,
None | Some("http") | Some("streamable_http")
) {
return None;
}
if contains_env_placeholder(&url) {
return None;
}
table.insert("url".to_string(), TomlValue::String(url));
if let Some(headers) = server_config.get("headers").and_then(JsonValue::as_object) {
append_header_config(&mut table, headers)?;
}
} else {
return None;
}
Some(table)
}
fn mcp_server_is_disabled(
server_name: &str,
server_config: &serde_json::Map<String, JsonValue>,
enabled_servers: &[String],
disabled_servers: &BTreeSet<String>,
) -> bool {
server_config
.get("enabled")
.and_then(JsonValue::as_bool)
.is_some_and(|enabled| !enabled)
|| server_config
.get("disabled")
.and_then(JsonValue::as_bool)
.unwrap_or(false)
|| (!enabled_servers.is_empty() && !enabled_servers.iter().any(|name| name == server_name))
|| disabled_servers.contains(server_name)
}
fn append_header_config(
table: &mut toml::map::Map<String, TomlValue>,
headers: &serde_json::Map<String, JsonValue>,
) -> Option<()> {
let mut static_headers = toml::map::Map::new();
let mut env_headers = toml::map::Map::new();
for (key, value) in headers {
let header_value = json_string(value).unwrap_or_else(|| value.to_string());
if key.eq_ignore_ascii_case("authorization")
&& let Some(token_env) = header_value
.strip_prefix("Bearer ")
.and_then(parse_env_placeholder)
{
table.insert(
"bearer_token_env_var".to_string(),
TomlValue::String(token_env),
);
continue;
}
if let Some(env_var) = parse_env_placeholder(&header_value) {
env_headers.insert(key.clone(), TomlValue::String(env_var));
} else if contains_env_placeholder(&header_value) {
return None;
} else {
static_headers.insert(key.clone(), TomlValue::String(header_value));
}
}
if !static_headers.is_empty() {
table.insert("http_headers".to_string(), TomlValue::Table(static_headers));
}
if !env_headers.is_empty() {
table.insert(
"env_http_headers".to_string(),
TomlValue::Table(env_headers),
);
}
Some(())
}
fn append_env_config(
table: &mut toml::map::Map<String, TomlValue>,
env: &serde_json::Map<String, JsonValue>,
) -> Option<()> {
let mut static_env = toml::map::Map::new();
let mut env_vars = Vec::new();
for (key, value) in env {
let env_value = json_string(value).unwrap_or_else(|| value.to_string());
if parse_env_placeholder(&env_value).as_deref() == Some(key.as_str()) {
env_vars.push(TomlValue::String(key.clone()));
} else if contains_env_placeholder(&env_value) {
return None;
} else {
static_env.insert(key.clone(), TomlValue::String(env_value));
}
}
if !env_vars.is_empty() {
table.insert("env_vars".to_string(), TomlValue::Array(env_vars));
}
if !static_env.is_empty() {
table.insert("env".to_string(), TomlValue::Table(static_env));
}
Some(())
}
pub(crate) fn parse_env_placeholder(value: &str) -> Option<String> {
let inner = value.strip_prefix("${")?.strip_suffix('}')?;
let name = inner
.split_once(":-")
.map_or(inner, |(name, _default)| name);
let mut chars = name.chars();
let first = chars.next()?;
if !(first == '_' || first.is_ascii_alphabetic()) {
return None;
}
if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
return None;
}
Some(name.to_string())
}
fn contains_env_placeholder(value: &str) -> bool {
value.contains("${")
}
fn json_string_vec(value: &JsonValue) -> Vec<String> {
match value {
JsonValue::Array(values) => values.iter().filter_map(json_string).collect(),
_ => json_string(value).into_iter().collect(),
}
}
fn json_string(value: &JsonValue) -> Option<String> {
match value {
JsonValue::Null => None,
JsonValue::String(value) => Some(value.clone()),
JsonValue::Bool(value) => Some(value.to_string()),
JsonValue::Number(value) => Some(value.to_string()),
JsonValue::Array(_) | JsonValue::Object(_) => None,
}
}
pub(crate) fn external_agent_project_config_file() -> &'static str {
EXTERNAL_AGENT_PROJECT_CONFIG_FILE
}
@@ -62,7 +62,7 @@ fn discover_project_memory(
continue;
}
let project_key = project_entry.file_name().to_string_lossy().into_owned();
let project_cwd = detect_project_cwd(&project_entry.path())?;
let project_cwd = project_cwd_from_sessions(&project_entry.path())?;
collect_markdown_files(
&memory_root,
&memory_root,
@@ -74,7 +74,7 @@ fn discover_project_memory(
Ok(())
}
fn detect_project_cwd(project_root: &Path) -> io::Result<Option<PathBuf>> {
fn project_cwd_from_sessions(project_root: &Path) -> io::Result<Option<PathBuf>> {
let mut sessions = fs::read_dir(project_root)?
.collect::<Result<Vec<_>, _>>()?
.into_iter()
@@ -1,8 +1,5 @@
use super::ExternalAgentConfigMigrationItem;
use super::ExternalAgentConfigMigrationItemType;
use super::MigrationDetails;
use codex_external_agent_migration::ExternalMemoryFile;
use codex_external_agent_migration::discover_external_memory_files;
use crate::ExternalMemoryFile;
use crate::discover_external_memory_files;
use codex_rollout::StateDbHandle;
use serde::Serialize;
use std::collections::BTreeMap;
@@ -48,31 +45,6 @@ struct ProjectScope<'a> {
cwd: &'a Path,
}
pub(super) fn detect(
codex_home: &Path,
external_agent_home: &Path,
) -> io::Result<Option<ExternalAgentConfigMigrationItem>> {
let memory_files = discover_external_memory_files(external_agent_home)?;
let memory = projects_needing_import(codex_home, &memory_files)?;
if memory.is_empty() {
return Ok(None);
}
Ok(Some(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Memory,
description: format!(
"Import memory files from {} to {}",
external_agent_home.join("projects").display(),
resources_root(codex_home).display()
),
cwd: None,
details: Some(MigrationDetails {
memory: memory.into_iter().collect(),
..Default::default()
}),
}))
}
pub(super) async fn import(
codex_home: &Path,
external_agent_home: &Path,
@@ -112,7 +84,7 @@ pub(super) async fn import(
Ok(copy_outcome)
}
fn projects_needing_import(
pub(crate) fn projects_needing_import(
codex_home: &Path,
memory_files: &[ExternalMemoryFile],
) -> io::Result<BTreeSet<String>> {
@@ -390,5 +362,5 @@ fn invalid_data_error(message: impl Into<String>) -> io::Error {
}
#[cfg(test)]
#[path = "memory_tests.rs"]
#[path = "memory_import_tests.rs"]
mod tests;
@@ -1,8 +1,11 @@
use codex_external_agent_migration::RewriteProfile;
use codex_external_agent_migration::sessions::ExternalAgentSessionMigration;
use codex_external_agent_migration::sessions::SessionMetadataMode;
use codex_external_agent_migration::sessions::detect_recent_cla_sessions;
use codex_external_agent_migration::sessions::detect_recent_cur_sessions;
use crate::ClaSource;
use crate::CurSource;
use crate::RewriteProfile;
use crate::detect::plugins;
use crate::detect::sessions::detect_recent_cla_sessions;
use crate::detect::sessions::detect_recent_cur_sessions;
use crate::sessions::ExternalAgentSessionMigration;
use crate::sessions::SessionMetadataMode;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::collections::HashSet;
@@ -11,15 +14,12 @@ use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
use super::MigrationDetails;
use super::source_cla;
use super::source_cur;
use crate::model::MigrationDetails;
use crate::scope::MigrationScope;
use crate::source_cla;
use crate::source_cur;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct InstructionSourceGroup {
pub(super) scope: PathBuf,
pub(super) sources: Vec<PathBuf>,
}
pub(super) use crate::InstructionSourceGroup;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct MarketplaceImportSource {
@@ -49,18 +49,10 @@ pub(super) enum ExternalAgentSource {
Cur,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum SourceFeature {
Config,
Memory,
Plugins,
Sessions,
}
impl ExternalAgentSource {
pub(super) fn from_migration_source(migration_source: Option<&str>) -> Self {
if migration_source
.is_some_and(|source| source.eq_ignore_ascii_case(source_cur::MIGRATION_SOURCE))
.is_some_and(|source| source.eq_ignore_ascii_case(CurSource::MIGRATION_SOURCE))
{
Self::Cur
} else {
@@ -70,33 +62,23 @@ impl ExternalAgentSource {
pub(super) fn config_dir(self) -> &'static str {
match self {
Self::Cla => source_cla::CONFIG_DIR,
Self::Cur => source_cur::CONFIG_DIR,
Self::Cla => ClaSource::CONFIG_DIR,
Self::Cur => CurSource::CONFIG_DIR,
}
}
pub(super) fn supports(self, feature: SourceFeature) -> bool {
match (self, feature) {
(
Self::Cla,
SourceFeature::Config
| SourceFeature::Memory
| SourceFeature::Plugins
| SourceFeature::Sessions,
) => true,
(
Self::Cur,
SourceFeature::Config | SourceFeature::Plugins | SourceFeature::Sessions,
) => true,
(Self::Cur, SourceFeature::Memory) => false,
pub(super) fn supports_memory(self) -> bool {
match self {
Self::Cla => true,
Self::Cur => false,
}
}
pub(super) fn settings_file_name(self, project_scope: bool) -> &'static str {
match (self, project_scope) {
(Self::Cla, _) => "settings.json",
(Self::Cur, false) => source_cur::HOME_CONFIG_FILE,
(Self::Cur, true) => source_cur::PROJECT_CONFIG_FILE,
pub(super) fn settings_file_name(self, scope: &MigrationScope) -> &'static str {
match (self, scope) {
(Self::Cla, _) => ClaSource::SETTINGS_FILE,
(Self::Cur, MigrationScope::Home) => CurSource::HOME_CONFIG_FILE,
(Self::Cur, MigrationScope::Repository { .. }) => CurSource::PROJECT_CONFIG_FILE,
}
}
@@ -106,30 +88,37 @@ impl ExternalAgentSource {
source_settings: &Path,
) -> io::Result<Option<JsonValue>> {
match self {
Self::Cla => source_cla::effective_settings(source_settings),
Self::Cur => source_cur::effective_settings(source_config_dir, source_settings),
Self::Cla => ClaSource::effective_settings(source_settings),
Self::Cur => CurSource::effective_settings(source_config_dir, source_settings),
}
}
pub(super) fn detect_plugins(
pub(super) fn build_config(self, settings: &JsonValue) -> io::Result<TomlValue> {
match self {
Self::Cla => ClaSource::build_config(settings),
Self::Cur => CurSource::build_config(settings),
}
}
pub(super) fn plugin_migration(
self,
context: PluginDetectionContext<'_>,
) -> io::Result<Option<DetectedSourcePlugins>> {
match self {
Self::Cla => Ok(source_cla::detect_plugins(&context)),
Self::Cur if context.repo_root.is_none() => source_cur::detect_plugins(&context),
Self::Cla => Ok(plugins::detect_cla_plugins(&context)),
Self::Cur if context.repo_root.is_none() => plugins::detect_cur_plugins(&context),
Self::Cur => Ok(None),
}
}
pub(super) fn can_detect_plugins(self, settings: Option<&JsonValue>) -> bool {
pub(super) fn supports_plugin_migration(self, settings: Option<&JsonValue>) -> bool {
match self {
Self::Cla => source_cla::can_detect_plugins(settings),
Self::Cla => plugins::can_detect_cla_plugins(settings),
Self::Cur => true,
}
}
pub(super) fn detect_recent_sessions(
pub(super) fn recent_sessions(
self,
external_agent_home: &Path,
codex_home: &Path,
@@ -149,7 +138,7 @@ impl ExternalAgentSource {
pub(super) fn connector_metadata_roots(self, external_agent_home: &Path) -> Vec<PathBuf> {
match self {
Self::Cla => source_cla::connector_metadata_roots(external_agent_home),
Self::Cla => ClaSource::connector_metadata_roots(external_agent_home),
Self::Cur => Vec::new(),
}
}
@@ -161,7 +150,7 @@ impl ExternalAgentSource {
source_settings: &Path,
) -> io::Result<BTreeMap<String, MarketplaceImportSource>> {
match self {
Self::Cla => Ok(source_cla::effective_settings(source_settings)?
Self::Cla => Ok(ClaSource::effective_settings(source_settings)?
.as_ref()
.map(|settings| {
source_cla::marketplace_import_sources(
@@ -175,17 +164,6 @@ impl ExternalAgentSource {
}
}
pub(super) fn append_config(
self,
root: &mut toml::map::Map<String, TomlValue>,
settings: &serde_json::Map<String, JsonValue>,
) {
match self {
Self::Cla => source_cla::append_config(root, settings),
Self::Cur => source_cur::append_config(root, settings),
}
}
pub(super) fn build_mcp_config(
self,
source_root: &Path,
@@ -194,8 +172,8 @@ impl ExternalAgentSource {
settings: Option<&JsonValue>,
) -> io::Result<TomlValue> {
match self {
Self::Cla => source_cla::build_mcp_config(source_root, external_agent_home, settings),
Self::Cur => source_cur::build_mcp_config(source_config_dir),
Self::Cla => ClaSource::build_mcp_config(source_root, external_agent_home, settings),
Self::Cur => CurSource::build_mcp_config(source_config_dir),
}
}
@@ -215,8 +193,8 @@ impl ExternalAgentSource {
repo_root: &Path,
) -> io::Result<Vec<InstructionSourceGroup>> {
match self {
Self::Cla => source_cla::repo_instruction_source_groups(repo_root),
Self::Cur => source_cur::repo_instruction_source_groups(repo_root),
Self::Cla => ClaSource::repo_instruction_source_groups(repo_root),
Self::Cur => CurSource::repo_instruction_source_groups(repo_root),
}
}
@@ -225,15 +203,15 @@ impl ExternalAgentSource {
external_agent_home: &Path,
) -> io::Result<Vec<PathBuf>> {
match self {
Self::Cla => source_cla::home_instruction_sources(external_agent_home),
Self::Cla => ClaSource::home_instruction_sources(external_agent_home),
Self::Cur => Ok(Vec::new()),
}
}
pub(super) fn read_instruction_source(self, path: &Path) -> io::Result<String> {
match self {
Self::Cla => source_cla::read_instruction_source(path),
Self::Cur => source_cur::read_instruction_source(path),
Self::Cla => ClaSource::read_instruction_source(path),
Self::Cur => CurSource::read_instruction_source(path),
}
}
@@ -276,8 +254,8 @@ impl ExternalAgentSource {
target_agents: &Path,
) -> io::Result<Vec<String>> {
match self {
Self::Cla => source_cla::import_source_subagents(source_agents, target_agents),
Self::Cur => source_cur::import_source_subagents(source_agents, target_agents),
Self::Cla => ClaSource::import_subagents(source_agents, target_agents),
Self::Cur => CurSource::import_subagents(source_agents, target_agents),
}
}
@@ -287,15 +265,15 @@ impl ExternalAgentSource {
target_hooks: &Path,
) -> io::Result<Vec<String>> {
match self {
Self::Cla => source_cla::source_hook_event_names(source_dir, target_hooks),
Self::Cur => source_cur::source_hook_event_names(source_dir, target_hooks),
Self::Cla => ClaSource::hook_event_names(source_dir, target_hooks),
Self::Cur => CurSource::hook_event_names(source_dir, target_hooks),
}
}
pub(super) fn import_hooks(self, source_dir: &Path, target_hooks: &Path) -> io::Result<bool> {
match self {
Self::Cla => source_cla::import_source_hooks(source_dir, target_hooks),
Self::Cur => source_cur::import_source_hooks(source_dir, target_hooks),
Self::Cla => ClaSource::import_hooks(source_dir, target_hooks),
Self::Cur => CurSource::import_hooks(source_dir, target_hooks),
}
}
@@ -0,0 +1,139 @@
use crate::sessions::ExternalAgentSessionMigration;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAgentConfigDetectOptions {
pub include_home: bool,
pub include_memory: bool,
pub cwds: Option<Vec<PathBuf>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExternalAgentConfigMigrationItemType {
Config,
Skills,
AgentsMd,
Plugins,
McpServerConfig,
Subagents,
Hooks,
Commands,
Memory,
Sessions,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginsMigration {
pub marketplace_name: String,
pub plugin_names: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NamedMigration {
pub name: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MigrationDetails {
pub plugins: Vec<PluginsMigration>,
pub skills: Vec<NamedMigration>,
pub sessions: Vec<ExternalAgentSessionMigration>,
pub mcp_servers: Vec<NamedMigration>,
pub hooks: Vec<NamedMigration>,
pub subagents: Vec<NamedMigration>,
pub commands: Vec<NamedMigration>,
pub memory: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingPluginImport {
pub cwd: Option<PathBuf>,
pub description: String,
pub details: MigrationDetails,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub 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 struct ExternalAgentConfigImportOutcome {
pub pending_plugin_imports: Vec<PendingPluginImport>,
pub item_results: Vec<ExternalAgentConfigImportItemResult>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub 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 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 fn record_error(&mut self, raw_error: ExternalAgentConfigImportRawError) {
self.error_count = self.error_count.saturating_add(1);
self.raw_errors.push(raw_error);
}
pub 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 struct ExternalAgentConfigImportSuccess {
pub item_type: ExternalAgentConfigMigrationItemType,
pub cwd: Option<PathBuf>,
pub source: Option<String>,
pub target: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAgentConfigImportRawError {
pub item_type: ExternalAgentConfigMigrationItemType,
pub error_type: Option<String>,
pub sub_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 struct ExternalAgentConfigMigrationItem {
pub item_type: ExternalAgentConfigMigrationItemType,
pub description: String,
pub cwd: Option<PathBuf>,
pub details: Option<MigrationDetails>,
}
@@ -0,0 +1,260 @@
use codex_analytics::PluginInstallSource;
use codex_core::config::ConfigBuilder;
use codex_core_plugins::PluginInstallError;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::marketplace::MarketplaceError;
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 std::collections::BTreeMap;
use std::io;
use std::path::Path;
use crate::migration_source::MarketplaceImportSource;
use crate::model::MigrationDetails;
use crate::model::PluginImportOutcome;
use crate::reporting::plugin_import_raw_error;
use crate::reporting::record_plugin_import_errors;
use crate::scope::MigrationScope;
use crate::service::ExternalAgentConfigService;
use crate::utils::invalid_data_error;
impl ExternalAgentConfigService {
fn marketplace_import_sources(
&self,
cwd: Option<&Path>,
) -> io::Result<BTreeMap<String, MarketplaceImportSource>> {
let Some(scope) = MigrationScope::from_cwd(cwd)? else {
return Ok(BTreeMap::new());
};
let source_root = scope
.repo_root()
.unwrap_or(self.external_agent_home.as_path());
let source_settings = self.source_settings(&scope);
self.source.marketplace_import_sources(
self.external_agent_home.as_path(),
source_root,
&source_settings,
)
}
pub(super) fn partition_plugin_migration_details(
&self,
cwd: Option<&Path>,
details: MigrationDetails,
) -> io::Result<(Option<MigrationDetails>, Option<MigrationDetails>)> {
let import_sources = self.marketplace_import_sources(cwd)?;
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 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 config = ConfigBuilder::default()
.codex_home(self.codex_home.clone())
.fallback_cwd(Some(
cwd.map(Path::to_path_buf)
.unwrap_or_else(|| self.codex_home.clone()),
))
.build()
.await
.map_err(|err| io::Error::other(format!("failed to load config: {err}")))?;
let requirements = config.config_layer_stack.requirements().clone();
let mut outcome = PluginImportOutcome::default();
let plugins_manager = PluginsManager::new(self.codex_home.clone())
.with_plugin_install_source(PluginInstallSource::ExternalAgentMigration);
if let Some(analytics_events_client) = self.analytics_events_client.clone() {
plugins_manager.set_analytics_events_client(analytics_events_client);
}
let configured_marketplace_paths = plugins_manager
.list_marketplaces_for_config(
&config.plugins_config_input(),
&[],
/*include_openai_curated*/ true,
)
.map_err(|err| {
invalid_data_error(format!("failed to list configured marketplaces: {err}"))
})?
.marketplaces
.into_iter()
.map(|marketplace| (marketplace.name, marketplace.path))
.collect::<BTreeMap<_, _>>();
let import_sources = self.marketplace_import_sources(cwd)?;
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 marketplace_path = if let Some(marketplace_path) =
configured_marketplace_paths.get(&marketplace_name)
{
outcome
.succeeded_marketplaces
.push(marketplace_name.clone());
marketplace_path.clone()
} else {
let Some(import_source) = import_sources.get(&marketplace_name).cloned() 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(),
};
match add_marketplace(self.codex_home.clone(), requirements.clone(), request).await
{
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;
}
}
};
let install_config = match ConfigBuilder::default()
.codex_home(self.codex_home.clone())
.fallback_cwd(Some(
cwd.map(Path::to_path_buf)
.unwrap_or_else(|| self.codex_home.clone()),
))
.build()
.await
{
Ok(config) => config,
Err(err) => {
record_plugin_import_errors(
&mut outcome,
cwd,
&plugin_ids,
"plugin_import",
format!("failed to reload config after adding marketplace: {err}"),
);
outcome.failed_plugin_ids.extend(plugin_ids);
continue;
}
};
for plugin_name in plugin_names {
match plugins_manager
.install_plugin(
&install_config.config_layer_stack,
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());
let sub_error_type = err.sub_error_type();
let mut raw_error = plugin_import_raw_error(
cwd,
"plugin_import",
err.to_string(),
Some(plugin_id),
);
raw_error.sub_error_type = sub_error_type;
if matches!(
err,
PluginInstallError::Marketplace(
MarketplaceError::PluginNotFound { .. }
)
) {
raw_error.error_type = Some("plugin_not_found".to_string());
}
outcome.raw_errors.push(raw_error);
}
}
}
}
Ok(outcome)
}
}
@@ -0,0 +1,105 @@
use std::path::Path;
use crate::model::ExternalAgentConfigImportItemResult;
use crate::model::ExternalAgentConfigImportRawError;
use crate::model::ExternalAgentConfigMigrationItemType;
use crate::model::PluginImportOutcome;
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::Memory => "memory",
ExternalAgentConfigMigrationItemType::Sessions => "sessions",
}
}
pub 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,
sub_error_type: None,
failure_stage: failure_stage.to_string(),
message: message.into(),
cwd: result.cwd.clone(),
source,
});
}
pub(super) 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()))
}));
}
pub(super) 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,
sub_error_type: None,
failure_stage: failure_stage.to_string(),
message,
cwd: cwd.map(Path::to_path_buf),
source,
}
}
pub(super) 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
}
pub(super) 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);
}
@@ -0,0 +1,130 @@
/// Describes source-specific terms that should be rewritten in migrated artifacts.
#[derive(Clone, Copy)]
pub struct RewriteProfile {
doc_file_name: &'static str,
term_variants: &'static [&'static str],
case_sensitive_term_variants: &'static [&'static str],
}
impl RewriteProfile {
pub const fn new(doc_file_name: &'static str, term_variants: &'static [&'static str]) -> Self {
Self {
doc_file_name,
term_variants,
case_sensitive_term_variants: &[],
}
}
pub const fn with_case_sensitive_term_variants(
mut self,
term_variants: &'static [&'static str],
) -> Self {
self.case_sensitive_term_variants = term_variants;
self
}
pub const fn doc_file_name(self) -> &'static str {
self.doc_file_name
}
pub const fn term_variants(self) -> &'static [&'static str] {
self.term_variants
}
pub const fn case_sensitive_term_variants(self) -> &'static [&'static str] {
self.case_sensitive_term_variants
}
/// Rewrites source-specific documentation names and product terms to their Codex forms.
pub fn rewrite(self, content: &str) -> String {
let mut rewritten =
replace_case_insensitive_with_boundaries(content, self.doc_file_name, "AGENTS.md");
for from in self.term_variants {
rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex");
}
for from in self.case_sensitive_term_variants {
rewritten = replace_with_boundaries(&rewritten, from, "Codex");
}
rewritten
}
}
fn replace_with_boundaries(input: &str, needle: &str, replacement: &str) -> String {
if needle.is_empty() {
return input.to_string();
}
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) = input[search_start..].find(needle) {
let start = search_start + relative_pos;
let end = start + needle.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 = end;
}
if last_emitted == 0 {
return input.to_string();
}
output.push_str(&input[last_emitted..]);
output
}
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'_'
}
#[cfg(test)]
#[path = "rewrite_tests.rs"]
mod tests;
@@ -0,0 +1,13 @@
use super::*;
use pretty_assertions::assert_eq;
const PROFILE: RewriteProfile = RewriteProfile::new("SOURCE.md", &["source agent"])
.with_case_sensitive_term_variants(&["Source"]);
#[test]
fn rewrites_terms_only_at_word_boundaries() {
assert_eq!(
PROFILE.rewrite("SOURCE.md Source source agent source_agent"),
"AGENTS.md Codex Codex source_agent"
);
}
@@ -0,0 +1,71 @@
use std::io;
use std::path::Path;
use std::path::PathBuf;
/// The filesystem boundary within which migration detection or import runs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum MigrationScope {
Home,
Repository { root: PathBuf },
}
impl MigrationScope {
pub(super) fn home() -> Self {
Self::Home
}
pub(super) fn from_cwd(cwd: Option<&Path>) -> io::Result<Option<Self>> {
let Some(cwd) = cwd.filter(|cwd| !cwd.as_os_str().is_empty()) else {
return Ok(Some(Self::Home));
};
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(Self::Repository { root: current }));
}
if !current.pop() {
break;
}
}
Ok(Some(Self::Repository { root: fallback }))
}
pub(super) fn repo_root(&self) -> Option<&Path> {
match self {
Self::Home => None,
Self::Repository { root } => Some(root),
}
}
pub(super) fn cwd(&self) -> Option<PathBuf> {
self.repo_root().map(Path::to_path_buf)
}
pub(super) fn is_home(&self) -> bool {
matches!(self, Self::Home)
}
}
#[cfg(test)]
#[path = "scope_tests.rs"]
mod tests;
@@ -0,0 +1,35 @@
use super::MigrationScope;
use pretty_assertions::assert_eq;
#[test]
fn missing_cwd_selects_home_scope() {
assert_eq!(
MigrationScope::from_cwd(/*cwd*/ None).expect("resolve scope"),
Some(MigrationScope::Home)
);
}
#[test]
fn nested_cwd_selects_repository_root() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(root.path().join(".git")).expect("create git directory");
let nested = root.path().join("src").join("nested");
std::fs::create_dir_all(&nested).expect("create nested directory");
assert_eq!(
MigrationScope::from_cwd(Some(&nested)).expect("resolve scope"),
Some(MigrationScope::Repository {
root: root.path().to_path_buf(),
})
);
}
#[test]
fn nonexistent_cwd_has_no_scope() {
let root = tempfile::tempdir().expect("tempdir");
assert_eq!(
MigrationScope::from_cwd(Some(&root.path().join("missing"))).expect("resolve scope"),
None
);
}
@@ -0,0 +1,795 @@
use crate::config_values::is_empty_toml_table;
use crate::config_values::merge_missing_mcp_servers;
use crate::config_values::merge_missing_toml_values;
use crate::config_values::migrated_mcp_server_names;
use crate::config_values::write_toml_file;
use crate::memory_import;
use crate::migration_source::ExternalAgentSource;
use crate::migration_source::InstructionSourceGroup;
pub use crate::model::ExternalAgentConfigDetectOptions;
pub use crate::model::ExternalAgentConfigImportItemResult;
pub use crate::model::ExternalAgentConfigImportOutcome;
pub use crate::model::ExternalAgentConfigImportRawError;
pub use crate::model::ExternalAgentConfigImportSuccess;
pub use crate::model::ExternalAgentConfigMigrationItem;
pub use crate::model::ExternalAgentConfigMigrationItemType;
pub use crate::model::MigrationDetails;
pub use crate::model::NamedMigration;
pub use crate::model::PendingPluginImport;
pub use crate::model::PluginImportOutcome;
pub use crate::model::PluginsMigration;
use crate::reporting::emit_migration_metric;
#[cfg(test)]
use crate::reporting::migration_metric_tags;
pub use crate::reporting::record_import_error;
use crate::scope::MigrationScope;
use crate::sessions::SessionMetadataMode;
#[cfg(test)]
use crate::source_cla::KNOWN_MARKETPLACES_PATH as EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH;
#[cfg(test)]
use crate::source_cla::OFFICIAL_MARKETPLACE_NAME as EXTERNAL_OFFICIAL_MARKETPLACE_NAME;
use crate::utils::copy_dir_recursive;
use crate::utils::display_source_paths;
use crate::utils::invalid_data_error;
use crate::utils::is_missing_or_empty_text_file;
pub(super) use crate::utils::read_json_file as read_external_settings;
use crate::utils::rewrite_external_agent_terms;
use codex_analytics::AnalyticsEventsClient;
use codex_core::config::Config;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
use codex_protocol::protocol::Product;
use codex_rollout::StateDbHandle;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
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;
#[cfg(test)]
const EXTERNAL_AGENT_DIR: &str = crate::ClaSource::CONFIG_DIR;
#[cfg(test)]
const EXTERNAL_AGENT_CONFIG_MD: &str = crate::ClaSource::CONFIG_MD;
const EXTERNAL_AGENT_CONFIG_IMPORT_METRIC: &str = "codex.external_agent_config.import";
#[derive(Clone)]
pub struct ExternalAgentConfigService {
pub(super) codex_home: PathBuf,
pub(super) connector_metadata_roots: Vec<PathBuf>,
pub(crate) external_agent_home: PathBuf,
pub(crate) analytics_events_client: Option<AnalyticsEventsClient>,
pub(crate) source: ExternalAgentSource,
state_db: Option<StateDbHandle>,
}
impl ExternalAgentConfigService {
pub fn new(
codex_home: PathBuf,
analytics_events_client: AnalyticsEventsClient,
state_db: Option<StateDbHandle>,
) -> Self {
let source = ExternalAgentSource::default();
let external_agent_home = default_external_agent_home(source);
let connector_metadata_roots = source.connector_metadata_roots(&external_agent_home);
Self {
codex_home,
connector_metadata_roots,
external_agent_home,
analytics_events_client: Some(analytics_events_client),
source,
state_db,
}
}
pub fn with_migration_source(&self, migration_source: Option<&str>) -> Self {
let source = ExternalAgentSource::from_migration_source(migration_source);
let external_agent_home = default_external_agent_home(source);
let connector_metadata_roots = source.connector_metadata_roots(&external_agent_home);
Self {
codex_home: self.codex_home.clone(),
connector_metadata_roots,
external_agent_home,
analytics_events_client: self.analytics_events_client.clone(),
source,
state_db: self.state_db.clone(),
}
}
pub fn session_metadata_mode(&self) -> SessionMetadataMode {
self.source.session_metadata_mode()
}
pub fn connector_metadata_roots(&self) -> &[PathBuf] {
&self.connector_metadata_roots
}
pub fn codex_home(&self) -> &Path {
&self.codex_home
}
#[cfg(test)]
fn new_for_test(codex_home: PathBuf, external_agent_home: PathBuf) -> Self {
let source = ExternalAgentSource::default();
let connector_metadata_roots = source.connector_metadata_roots(&external_agent_home);
Self {
codex_home,
connector_metadata_roots,
external_agent_home,
analytics_events_client: None,
source,
state_db: None,
}
}
pub 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 async fn import(
&self,
migration_items: Vec<ExternalAgentConfigMigrationItem>,
) -> 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::Memory if self.source.supports_memory() => {
async {
let selected_memory = migration_item
.details
.as_ref()
.map(|details| details.memory.as_slice())
.unwrap_or_default();
let memory_outcome = memory_import::import(
&self.codex_home,
&self.external_agent_home,
self.state_db.as_ref(),
selected_memory,
)
.await?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Memory,
/*skills_count*/ None,
);
let target_path = memory_import::resources_root(&self.codex_home);
for project_key in memory_outcome.synchronized_projects {
item_result.record_success(
Some(project_key),
Some(target_path.display().to_string()),
);
}
for failure in memory_outcome.failures {
record_import_error(
&mut item_result,
"memory_import",
failure.message,
Some(failure.project_key),
);
}
Ok(())
}
.await
}
ExternalAgentConfigMigrationItemType::Memory => Err(invalid_data_error(
"memory import is not supported for the selected migration source".to_string(),
)),
ExternalAgentConfigMigrationItemType::Sessions => Ok(()),
};
if let Err(err) = import_result
&& item_type != ExternalAgentConfigMigrationItemType::Plugins
{
let message = err.to_string();
let error_type = if message.contains("invalid existing config.toml") {
"invalid_existing_config"
} else {
"external_agent_config_import_error"
};
item_result.record_error(ExternalAgentConfigImportRawError {
item_type,
error_type: Some(error_type.to_string()),
sub_error_type: None,
failure_stage: "import_request_failed".to_string(),
message,
cwd: item_result.cwd.clone(),
source: None,
});
}
outcome.item_results.push(item_result);
}
outcome
}
pub(crate) 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"))
}
pub(crate) fn source_config_dir(&self, scope: &MigrationScope) -> PathBuf {
scope.repo_root().map_or_else(
|| self.external_agent_home.clone(),
|repo_root| repo_root.join(self.source.config_dir()),
)
}
pub(crate) fn source_settings(&self, scope: &MigrationScope) -> PathBuf {
self.source_config_dir(scope)
.join(self.source.settings_file_name(scope))
}
pub(crate) fn effective_source_settings(
&self,
scope: &MigrationScope,
) -> io::Result<Option<JsonValue>> {
let source_settings = self.source_settings(scope);
self.source
.effective_settings(self.source_config_dir(scope).as_path(), &source_settings)
}
pub(crate) fn build_mcp_config(
&self,
scope: &MigrationScope,
settings: Option<JsonValue>,
) -> io::Result<TomlValue> {
let settings = self.mcp_settings(scope, settings)?;
self.source.build_mcp_config(
self.source_root(scope).as_path(),
self.source_config_dir(scope).as_path(),
self.external_agent_home.as_path(),
settings.as_ref(),
)
}
pub(crate) fn repo_agents_md_source_groups(
&self,
repo_root: &Path,
) -> io::Result<Vec<InstructionSourceGroup>> {
self.source.repo_instruction_source_groups(repo_root)
}
pub(crate) fn home_agents_md_sources(&self) -> io::Result<Vec<PathBuf>> {
self.source
.home_instruction_sources(self.external_agent_home.as_path())
}
fn mcp_settings(
&self,
scope: &MigrationScope,
source_settings: Option<JsonValue>,
) -> io::Result<Option<JsonValue>> {
if !scope.is_home() && source_settings.is_none() {
let home_scope = MigrationScope::home();
let home_settings = self.source_settings(&home_scope);
match self.effective_source_settings(&home_scope) {
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)
}
}
pub(crate) fn source_root(&self, scope: &MigrationScope) -> PathBuf {
scope.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 import_config(&self, cwd: Option<&Path>) -> io::Result<Option<(String, String)>> {
let Some(scope) = MigrationScope::from_cwd(cwd)? else {
return Ok(None);
};
let source_settings = self.source_settings(&scope);
let target_config = match &scope {
MigrationScope::Home => self.codex_home.join("config.toml"),
MigrationScope::Repository { root } => root.join(".codex").join("config.toml"),
};
let Some(settings) = self.effective_source_settings(&scope)? else {
return Ok(None);
};
let migrated = self.source.build_config(&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 Some(scope) = MigrationScope::from_cwd(cwd)? else {
return Ok(Vec::new());
};
let target_config = match &scope {
MigrationScope::Home => self.codex_home.join("config.toml"),
MigrationScope::Repository { root } => root.join(".codex").join("config.toml"),
};
let settings = self.effective_source_settings(&scope)?;
let migrated = self.build_mcp_config(&scope, settings)?;
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 Some(scope) = MigrationScope::from_cwd(cwd)? else {
return Ok(Vec::new());
};
let (source_agents, target_agents) = match scope {
MigrationScope::Home => (
self.external_agent_home.join("agents"),
self.codex_home.join("agents"),
),
MigrationScope::Repository { root } => (
root.join(self.source.config_dir()).join("agents"),
root.join(".codex").join("agents"),
),
};
self.source.import_subagents(&source_agents, &target_agents)
}
fn import_hooks(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
let Some(scope) = MigrationScope::from_cwd(cwd)? else {
return Ok(Vec::new());
};
let target_hooks = match &scope {
MigrationScope::Home => self.codex_home.join("hooks.json"),
MigrationScope::Repository { root } => root.join(".codex").join("hooks.json"),
};
let source_external_agent_dir = self.source_config_dir(&scope);
let hook_names = self
.source
.hook_event_names(&source_external_agent_dir, &target_hooks)?;
if self
.source
.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 Some(scope) = MigrationScope::from_cwd(cwd)? else {
return Ok(Vec::new());
};
let (source_commands, target_skills) = match scope {
MigrationScope::Home => (
self.external_agent_home.join("commands"),
self.home_target_skills_dir(),
),
MigrationScope::Repository { root } => (
root.join(self.source.config_dir()).join("commands"),
root.join(".agents").join("skills"),
),
};
self.source
.import_commands(&source_commands, &target_skills)
}
fn import_skills(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
let Some(scope) = MigrationScope::from_cwd(cwd)? else {
return Ok(Vec::new());
};
let (source_skills, target_skills) = match scope {
MigrationScope::Home => (
self.external_agent_home.join("skills"),
self.home_target_skills_dir(),
),
MigrationScope::Repository { root } => (
root.join(self.source.config_dir()).join("skills"),
root.join(".agents").join("skills"),
),
};
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, self.source.rewrite_profile())?;
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 Some(scope) = MigrationScope::from_cwd(cwd)? else {
return Ok(None);
};
let (source_agents_md, target_agents_md) = match scope {
MigrationScope::Repository { root } => {
let Some(group) = self
.repo_agents_md_source_groups(&root)?
.into_iter()
.find(|group| group.scope == root)
else {
return Ok(None);
};
let target_agents_md = group.scope.join("AGENTS.md");
(group.sources, target_agents_md)
}
MigrationScope::Home => {
let source_agents_md = self.home_agents_md_sources()?;
if source_agents_md.is_empty() {
return Ok(None);
}
(source_agents_md, self.codex_home.join("AGENTS.md"))
}
};
if !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)?;
let source_contents = source_agents_md
.iter()
.map(|source| {
self.source.read_instruction_source(source).map(|contents| {
rewrite_external_agent_terms(&contents, self.source.rewrite_profile())
})
})
.collect::<io::Result<Vec<_>>>()?
.join("\n\n");
fs::write(&target_agents_md, source_contents)?;
Ok(Some((
display_source_paths(&source_agents_md),
target_agents_md.display().to_string(),
)))
}
}
fn default_external_agent_home(source: ExternalAgentSource) -> PathBuf {
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
return PathBuf::from(home).join(source.config_dir());
}
PathBuf::from(source.config_dir())
}
pub(crate) 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_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)
}
pub(crate) fn missing_subdirectory_names(source: &Path, target: &Path) -> io::Result<Vec<String>> {
let source_names = collect_subdirectory_names(source)?;
let target_names = collect_subdirectory_names(target)?;
let mut missing_names = source_names
.into_iter()
.filter(|name| !target_names.contains(name))
.map(|name| name.to_string_lossy().into_owned())
.collect::<Vec<_>>();
missing_names.sort();
Ok(missing_names)
}
pub(crate) fn named_migrations(names: Vec<String>) -> Vec<NamedMigration> {
names
.into_iter()
.map(|name| NamedMigration { name })
.collect()
}
#[cfg(test)]
#[path = "service_tests.rs"]
mod tests;
@@ -0,0 +1,79 @@
use super::*;
use pretty_assertions::assert_eq;
use std::io;
use tempfile::TempDir;
const EXTERNAL_AGENT_PROJECT_CONFIG_FILE: &str = ".claude.json";
const EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR: &str = ".claude-plugin";
const SOURCE_EXTERNAL_AGENT_NAME: &str = "claude";
const SOURCE_EXTERNAL_AGENT_DISPLAY_NAME: &str = "Claude";
const SOURCE_EXTERNAL_AGENT_PRODUCT_NAME: &str = "Claude Code";
const SOURCE_EXTERNAL_AGENT_UPPER_NAME: &str = "CLAUDE";
const SOURCE_EXTERNAL_AGENT_UPPER_PRODUCT_NAME: &str = "CLAUDE-CODE";
fn fixture_paths() -> (TempDir, PathBuf, PathBuf) {
let root = TempDir::new().expect("create tempdir");
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
let codex_home = root.path().join(".codex");
(root, external_agent_home, codex_home)
}
fn service_for_paths(
external_agent_home: PathBuf,
codex_home: PathBuf,
) -> ExternalAgentConfigService {
ExternalAgentConfigService::new_for_test(codex_home, external_agent_home)
}
fn github_plugin_details() -> MigrationDetails {
MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec!["formatter".to_string()],
}],
..Default::default()
}
}
fn assert_single_plugin_raw_error(
raw_errors: &[ExternalAgentConfigImportRawError],
failure_stage: &str,
source: &str,
error_type: Option<&str>,
) {
assert_eq!(raw_errors.len(), 1);
let raw_error = &raw_errors[0];
assert_eq!(
raw_error.item_type,
ExternalAgentConfigMigrationItemType::Plugins
);
assert_eq!(raw_error.failure_stage, failure_stage);
assert_eq!(raw_error.error_type.as_deref(), error_type);
assert_eq!(raw_error.sub_error_type, None);
assert_eq!(raw_error.cwd, None);
assert_eq!(raw_error.source.as_deref(), Some(source));
assert!(!raw_error.message.is_empty());
}
fn import_success(
item_type: ExternalAgentConfigMigrationItemType,
cwd: Option<PathBuf>,
source: impl Into<String>,
target: impl Into<String>,
) -> ExternalAgentConfigImportSuccess {
ExternalAgentConfigImportSuccess {
item_type,
cwd,
source: Some(source.into()),
target: Some(target.into()),
}
}
#[path = "service_tests/general.rs"]
mod general;
#[path = "service_tests/memory.rs"]
mod memory;
#[path = "service_tests/plugins.rs"]
mod plugins;
@@ -0,0 +1,8 @@
#[path = "general/config_import.rs"]
mod config_import;
#[path = "general/detection.rs"]
mod detection;
#[path = "general/repo_import.rs"]
mod repo_import;
@@ -0,0 +1,521 @@
use super::super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn import_repo_mcp_preserves_existing_same_named_server() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::write(
repo_root.join(".mcp.json"),
r#"{
"mcpServers": {
"mixedTransport": {
"command": "mcp-remote-proxy",
"args": [
"https://example.com/mixed-transport",
"--transport",
"http"
],
"url": "https://example.com/mixed-transport"
}
}
}"#,
)
.expect("write mcp");
fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir");
let existing_config = r#"[mcp_servers.mixedTransport]
url = "https://example.com/mixed-transport"
"#;
fs::write(
repo_root.join(".codex").join("config.toml"),
existing_config,
)
.expect("write config");
let service = service_for_paths(
root.path().join(EXTERNAL_AGENT_DIR),
root.path().join(".codex"),
);
assert_eq!(
service
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
include_memory: false,
cwds: Some(vec![repo_root.clone()]),
})
.await
.expect("detect"),
Vec::<ExternalAgentConfigMigrationItem>::new()
);
service
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
}])
.await;
assert_eq!(
fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
existing_config
);
}
#[tokio::test]
async fn detect_repo_mcp_lists_only_missing_servers() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::write(
repo_root.join(".mcp.json"),
r#"{
"mcpServers": {
"docs": {"command": "docs-server"},
"mixedTransport": {"command": "mcp-remote-proxy"}
}
}"#,
)
.expect("write mcp");
fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir");
fs::write(
repo_root.join(".codex").join("config.toml"),
r#"[mcp_servers.mixedTransport]
url = "https://example.com/mixed-transport"
"#,
)
.expect("write config");
let items = service_for_paths(
root.path().join(EXTERNAL_AGENT_DIR),
root.path().join(".codex"),
)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
include_memory: false,
cwds: Some(vec![repo_root.clone()]),
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: format!(
"Migrate MCP servers from {} into {}",
repo_root.display(),
repo_root.join(".codex").join("config.toml").display()
),
cwd: Some(repo_root),
details: Some(MigrationDetails {
mcp_servers: vec![NamedMigration {
name: "docs".to_string(),
}],
..Default::default()
}),
}]
);
}
#[tokio::test]
async fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
let (_root, external_agent_home, codex_home) = fixture_paths();
let agents_skills = codex_home
.parent()
.map(|parent| parent.join(".agents").join("skills"))
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
fs::create_dir_all(external_agent_home.join("skills").join("skill-a")).expect("create skills");
fs::write(
external_agent_home.join("settings.json"),
format!(r#"{{"model":"{SOURCE_EXTERNAL_AGENT_NAME}","permissions":{{"ask":["git push"]}},"env":{{"FOO":"bar","CI":false,"MAX_RETRIES":3,"MY_TEAM":"codex","IGNORED":null,"LIST":["a","b"],"MAP":{{"x":1}}}},"sandbox":{{"enabled":true,"network":{{"allowLocalBinding":true}}}}}}"#),
)
.expect("write settings");
fs::write(
external_agent_home
.join("skills")
.join("skill-a")
.join("SKILL.md"),
format!(
"Use {SOURCE_EXTERNAL_AGENT_PRODUCT_NAME} and {SOURCE_EXTERNAL_AGENT_UPPER_NAME} utilities."
),
)
.expect("write skill");
fs::write(
external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD),
format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"),
)
.expect("write agents");
service_for_paths(external_agent_home, codex_home.clone())
.import(vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: None,
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
cwd: None,
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: String::new(),
cwd: None,
details: None,
},
])
.await;
assert_eq!(
fs::read_to_string(codex_home.join("AGENTS.md")).expect("read agents"),
"Codex guidance"
);
let config: TomlValue =
toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config"))
.expect("parse config");
let expected: TomlValue = toml::from_str(
r#"
sandbox_mode = "workspace-write"
[shell_environment_policy]
inherit = "core"
[shell_environment_policy.set]
CI = "false"
FOO = "bar"
MAX_RETRIES = "3"
MY_TEAM = "codex"
"#,
)
.expect("parse expected config");
assert_eq!(config, expected);
assert_eq!(
fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md"))
.expect("read copied skill"),
"Use Codex and Codex utilities."
);
}
#[tokio::test]
async fn import_home_config_uses_local_settings_over_project_settings() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
r#"{"env":{"FOO":"project","PROJECT_ONLY":"yes"},"sandbox":{"enabled":false}}"#,
)
.expect("write project settings");
fs::write(
external_agent_home.join("settings.local.json"),
r#"{"env":{"FOO":"local","LOCAL_ONLY":true},"sandbox":{"enabled":true}}"#,
)
.expect("write local settings");
service_for_paths(external_agent_home, codex_home.clone())
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
cwd: None,
details: None,
}])
.await;
let config: TomlValue =
toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config"))
.expect("parse config");
let expected: TomlValue = toml::from_str(
r#"
sandbox_mode = "workspace-write"
[shell_environment_policy]
inherit = "core"
[shell_environment_policy.set]
FOO = "local"
LOCAL_ONLY = "true"
PROJECT_ONLY = "yes"
"#,
)
.expect("parse expected config");
assert_eq!(config, expected);
}
#[tokio::test]
async fn import_home_config_ignores_invalid_local_settings() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
r#"{"env":{"FOO":"project"},"sandbox":{"enabled":false}}"#,
)
.expect("write project settings");
fs::write(
external_agent_home.join("settings.local.json"),
"{invalid json",
)
.expect("write local settings");
service_for_paths(external_agent_home, codex_home.clone())
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
cwd: None,
details: None,
}])
.await;
assert_eq!(
fs::read_to_string(codex_home.join("config.toml")).expect("read config"),
"[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nFOO = \"project\"\n"
);
}
#[tokio::test]
async fn import_home_skips_empty_config_migration() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
format!(r#"{{"model":"{SOURCE_EXTERNAL_AGENT_NAME}","sandbox":{{"enabled":false}}}}"#),
)
.expect("write settings");
let outcome = service_for_paths(external_agent_home, codex_home.clone())
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
cwd: None,
details: None,
}])
.await;
assert_eq!(
outcome.item_results,
vec![ExternalAgentConfigImportItemResult {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
cwd: None,
success_count: 0,
error_count: 0,
successes: Vec::new(),
raw_errors: Vec::new(),
}]
);
assert!(!codex_home.join("config.toml").exists());
}
#[tokio::test]
async fn import_local_plugins_returns_completed_status() {
let (_root, external_agent_home, codex_home) = fixture_paths();
let marketplace_root = external_agent_home.join("my-marketplace");
let plugin_root = marketplace_root.join("plugins").join("cloudflare");
fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR))
.expect("create marketplace manifest dir");
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
external_agent_home.join("settings.json"),
serde_json::to_string_pretty(&serde_json::json!({
"enabledPlugins": {
"cloudflare@my-plugins": true
},
"extraKnownMarketplaces": {
"my-plugins": {
"source": "local",
"path": marketplace_root
}
}
}))
.expect("serialize settings"),
)
.expect("write settings");
fs::write(
marketplace_root
.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)
.join("marketplace.json"),
r#"{
"name": "my-plugins",
"plugins": [
{
"name": "cloudflare",
"source": "./plugins/cloudflare"
}
]
}"#,
)
.expect("write marketplace manifest");
fs::write(
plugin_root.join(".codex-plugin").join("plugin.json"),
r#"{"name":"cloudflare","version":"0.1.0"}"#,
)
.expect("write plugin manifest");
let outcome = service_for_paths(external_agent_home, codex_home.clone())
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: String::new(),
cwd: None,
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "my-plugins".to_string(),
plugin_names: vec!["cloudflare".to_string()],
}],
..Default::default()
}),
}])
.await;
assert_eq!(
outcome.pending_plugin_imports,
Vec::<PendingPluginImport>::new()
);
assert_eq!(
outcome.item_results,
vec![ExternalAgentConfigImportItemResult {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: String::new(),
cwd: None,
success_count: 1,
error_count: 0,
successes: vec![ExternalAgentConfigImportSuccess {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
cwd: None,
source: Some("cloudflare@my-plugins".to_string()),
target: Some("cloudflare@my-plugins".to_string()),
}],
raw_errors: Vec::new(),
}]
);
let config = fs::read_to_string(codex_home.join("config.toml")).expect("read config");
assert!(config.contains(r#"[plugins."cloudflare@my-plugins"]"#));
assert!(config.contains("enabled = true"));
}
#[tokio::test]
async fn import_git_plugins_returns_pending_async_status() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "owner/debug-marketplace"
}
}
}"#,
)
.expect("write settings");
let outcome = service_for_paths(external_agent_home, codex_home.clone())
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: String::new(),
cwd: None,
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec!["formatter".to_string()],
}],
..Default::default()
}),
}])
.await;
assert_eq!(
outcome.pending_plugin_imports,
vec![PendingPluginImport {
cwd: None,
description: String::new(),
details: MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec!["formatter".to_string()],
}],
..Default::default()
},
}]
);
assert_eq!(
outcome.item_results,
vec![ExternalAgentConfigImportItemResult {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: String::new(),
cwd: None,
success_count: 0,
error_count: 0,
successes: Vec::new(),
raw_errors: Vec::new(),
}]
);
assert!(!codex_home.join("config.toml").exists());
}
#[tokio::test]
async fn detect_home_skips_config_when_target_already_has_supported_fields() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
external_agent_home.join("settings.json"),
r#"{"env":{"FOO":"bar"},"sandbox":{"enabled":true}}"#,
)
.expect("write settings");
fs::write(
codex_home.join("config.toml"),
r#"
sandbox_mode = "workspace-write"
[shell_environment_policy]
inherit = "core"
[shell_environment_policy.set]
FOO = "bar"
"#,
)
.expect("write config");
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
include_memory: false,
cwds: None,
})
.await
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[tokio::test]
async fn detect_home_skips_skills_when_all_skill_directories_exist() {
let (_root, external_agent_home, codex_home) = fixture_paths();
let agents_skills = codex_home
.parent()
.map(|parent| parent.join(".agents").join("skills"))
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
fs::create_dir_all(external_agent_home.join("skills").join("skill-a")).expect("create source");
fs::create_dir_all(agents_skills.join("skill-a")).expect("create target");
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
include_memory: false,
cwds: None,
})
.await
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
@@ -0,0 +1,617 @@
use super::super::*;
use crate::sessions::ExternalAgentSessionMigration;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn detect_home_lists_config_skills_and_agents_md() {
let (_root, external_agent_home, codex_home) = fixture_paths();
let agents_skills = codex_home
.parent()
.map(|parent| parent.join(".agents").join("skills"))
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
fs::create_dir_all(external_agent_home.join("skills").join("skill-a")).expect("create skills");
fs::write(
external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD),
format!("{SOURCE_EXTERNAL_AGENT_NAME} rules"),
)
.expect("write external agent md");
fs::write(
external_agent_home.join("settings.json"),
format!(r#"{{"model":"{SOURCE_EXTERNAL_AGENT_NAME}","env":{{"FOO":"bar"}}}}"#),
)
.expect("write settings");
let items = service_for_paths(external_agent_home.clone(), codex_home.clone())
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
include_memory: false,
cwds: None,
})
.await
.expect("detect");
let expected = vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: format!(
"Migrate {} into {}",
external_agent_home.join("settings.json").display(),
codex_home.join("config.toml").display()
),
cwd: None,
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: format!(
"Migrate skills from {} to {}",
external_agent_home.join("skills").display(),
agents_skills.display()
),
cwd: None,
details: Some(MigrationDetails {
skills: named_migrations(vec!["skill-a".to_string()]),
..Default::default()
}),
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Migrate {} to {}",
external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD).display(),
codex_home.join("AGENTS.md").display()
),
cwd: None,
details: None,
},
];
assert_eq!(items, expected);
}
#[tokio::test]
async fn detect_home_lists_recent_sessions() {
let (root, external_agent_home, codex_home) = fixture_paths();
let project_root = root.path().join("repo");
let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let session_path = external_agent_home
.join("projects")
.join("repo")
.join("session.jsonl");
fs::create_dir_all(&project_root).expect("create project root");
fs::create_dir_all(session_path.parent().expect("session parent")).expect("create sessions");
fs::write(
&session_path,
serde_json::json!({
"type": "user",
"cwd": &project_root,
"timestamp": &recent_timestamp,
"message": { "content": "first request" },
})
.to_string(),
)
.expect("write session");
let items = service_for_paths(external_agent_home.clone(), codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
include_memory: false,
cwds: None,
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Sessions,
description: format!(
"Migrate recent sessions from {}",
external_agent_home.join("projects").display()
),
cwd: None,
details: Some(MigrationDetails {
plugins: Vec::new(),
sessions: vec![ExternalAgentSessionMigration {
path: session_path,
cwd: project_root,
title: Some("first request".to_string()),
}],
..Default::default()
}),
}]
);
}
#[tokio::test]
async fn detect_repo_lists_agents_md_for_each_cwd() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
let nested = repo_root.join("nested").join("child");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(&nested).expect("create nested");
fs::write(
repo_root.join(EXTERNAL_AGENT_CONFIG_MD),
format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"),
)
.expect("write source");
let items = service_for_paths(
root.path().join(EXTERNAL_AGENT_DIR),
root.path().join(".codex"),
)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
include_memory: false,
cwds: Some(vec![nested, repo_root.clone()]),
})
.await
.expect("detect");
let expected = vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Migrate {} to {}",
repo_root.join(EXTERNAL_AGENT_CONFIG_MD).display(),
repo_root.join("AGENTS.md").display(),
),
cwd: Some(repo_root.clone()),
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Migrate {} to {}",
repo_root.join(EXTERNAL_AGENT_CONFIG_MD).display(),
repo_root.join("AGENTS.md").display(),
),
cwd: Some(repo_root),
details: None,
},
];
assert_eq!(items, expected);
}
#[tokio::test]
async fn detect_repo_still_reports_non_plugin_items_when_home_config_is_invalid() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
let codex_home = root.path().join(".codex");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("skills")
.join("skill-a"),
)
.expect("create repo skills");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(codex_home.join("config.toml"), "this is not valid = [toml")
.expect("write invalid codex config");
fs::write(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
r#"{"env":{"FOO":"bar"}}"#,
)
.expect("write settings");
fs::write(
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("skills")
.join("skill-a")
.join("SKILL.md"),
format!(
"Use {SOURCE_EXTERNAL_AGENT_PRODUCT_NAME} and {SOURCE_EXTERNAL_AGENT_UPPER_NAME} utilities."
),
)
.expect("write skill");
fs::write(
repo_root
.join(EXTERNAL_AGENT_DIR)
.join(EXTERNAL_AGENT_CONFIG_MD),
format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"),
)
.expect("write agents");
let items = service_for_paths(root.path().join(EXTERNAL_AGENT_DIR), codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
include_memory: false,
cwds: Some(vec![repo_root.clone()]),
})
.await
.expect("detect");
assert_eq!(
items,
vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: format!(
"Migrate {} into {}",
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("settings.json")
.display(),
repo_root.join(".codex").join("config.toml").display()
),
cwd: Some(repo_root.clone()),
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: format!(
"Migrate skills from {} to {}",
repo_root.join(EXTERNAL_AGENT_DIR).join("skills").display(),
repo_root.join(".agents").join("skills").display()
),
cwd: Some(repo_root.clone()),
details: Some(MigrationDetails {
skills: named_migrations(vec!["skill-a".to_string()]),
..Default::default()
}),
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Migrate {} to {}",
repo_root
.join(EXTERNAL_AGENT_DIR)
.join(EXTERNAL_AGENT_CONFIG_MD)
.display(),
repo_root.join("AGENTS.md").display(),
),
cwd: Some(repo_root),
details: None,
},
]
);
}
#[tokio::test]
async fn detect_repo_lists_mcp_hooks_commands_and_subagents() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("commands")
.join("pr"),
)
.expect("create commands");
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR).join("agents")).expect("create agents");
fs::write(
repo_root.join(".mcp.json"),
r#"{"mcpServers":{"docs":{"command":"docs-server"}}}"#,
)
.expect("write mcp");
fs::write(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo external-agent","timeout":3},{"type":"http","url":"https://example.invalid/hook"}]}]}}"#,
)
.expect("write hooks");
fs::write(
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("commands")
.join("pr")
.join("review.md"),
"---\ndescription: Review PR\n---\nReview the pull request carefully.\n",
)
.expect("write command");
fs::write(
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("agents")
.join("researcher.md"),
"---\nname: researcher\ndescription: Research role\n---\nResearch carefully.\n",
)
.expect("write subagent");
let items = service_for_paths(
root.path().join(EXTERNAL_AGENT_DIR),
root.path().join(".codex"),
)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
include_memory: false,
cwds: Some(vec![repo_root.clone()]),
})
.await
.expect("detect");
assert_eq!(
items,
vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: format!(
"Migrate MCP servers from {} into {}",
repo_root.display(),
repo_root.join(".codex").join("config.toml").display()
),
cwd: Some(repo_root.clone()),
details: Some(MigrationDetails {
mcp_servers: vec![NamedMigration {
name: "docs".to_string(),
}],
..Default::default()
}),
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Hooks,
description: format!(
"Migrate hooks from {} to {}",
repo_root.join(EXTERNAL_AGENT_DIR).display(),
repo_root.join(".codex").join("hooks.json").display()
),
cwd: Some(repo_root.clone()),
details: Some(MigrationDetails {
hooks: vec![NamedMigration {
name: "PreToolUse".to_string(),
}],
..Default::default()
}),
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Commands,
description: format!(
"Migrate commands from {} to {}",
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("commands")
.display(),
repo_root.join(".agents").join("skills").display()
),
cwd: Some(repo_root.clone()),
details: Some(MigrationDetails {
commands: vec![NamedMigration {
name: "source-command-pr-review".to_string(),
}],
..Default::default()
}),
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Subagents,
description: format!(
"Migrate subagents from {} to {}",
repo_root.join(EXTERNAL_AGENT_DIR).join("agents").display(),
repo_root.join(".codex").join("agents").display()
),
cwd: Some(repo_root),
details: Some(MigrationDetails {
subagents: vec![NamedMigration {
name: "researcher".to_string(),
}],
..Default::default()
}),
},
]
);
}
#[tokio::test]
async fn detect_repo_skips_hooks_when_only_unsupported_hooks_exist() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir");
fs::write(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","if":"Bash(rm *)","command":"echo blocked"}]}],"UnsupportedEvent":[{"matcher":"worker","hooks":[{"type":"command","command":"echo started"}]}]}}"#,
)
.expect("write hooks");
let items = service_for_paths(
root.path().join(EXTERNAL_AGENT_DIR),
root.path().join(".codex"),
)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
include_memory: false,
cwds: Some(vec![repo_root]),
})
.await
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[tokio::test]
async fn import_repo_migrates_mcp_hooks_commands_and_subagents() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("commands")
.join("pr"),
)
.expect("create commands");
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR).join("agents")).expect("create agents");
fs::write(
repo_root.join(".mcp.json"),
r#"{
"mcpServers": {
"docs": {
"command": "docs-server",
"args": ["--stdio"],
"headers": {"X-Ignored": "unsupported for stdio"},
"env": {"DOCS_TOKEN": "${DOCS_TOKEN}", "STATIC": "yes"}
},
"api": {
"url": "https://example.com/mcp",
"args": ["ignored-for-http"],
"env": {"IGNORED": "unsupported for http"},
"headers": {
"Authorization": "Bearer ${API_TOKEN}",
"X-Team": "${TEAM}"
}
}
}
}"#,
)
.expect("write mcp");
fs::write(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo external-agent","timeout":3},{"type":"prompt","prompt":"skip"}]}],"Stop":[{"matcher":"ignored","hooks":[{"command":"echo done"}]}]}}"#,
)
.expect("write hooks");
fs::write(
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("commands")
.join("pr")
.join("review.md"),
"---\ndescription: Review PR\n---\nReview the pull request carefully.\n",
)
.expect("write command");
fs::write(
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("agents")
.join("researcher.md"),
format!("---\nname: researcher\ndescription: Research role\npermissionMode: acceptEdits\nskills: [deep-research]\ntools: Bash, Read\ndisallowedTools: WebFetch\neffort: high\n---\nResearch with {SOURCE_EXTERNAL_AGENT_PRODUCT_NAME} carefully.\n"),
)
.expect("write subagent");
service_for_paths(
root.path().join(EXTERNAL_AGENT_DIR),
root.path().join(".codex"),
)
.import(vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Hooks,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Commands,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Subagents,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
},
])
.await;
let config: TomlValue = toml::from_str(
&fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
)
.expect("parse config");
let expected_config: TomlValue = toml::from_str(
r#"
[mcp_servers.api]
url = "https://example.com/mcp"
bearer_token_env_var = "API_TOKEN"
[mcp_servers.api.env_http_headers]
X-Team = "TEAM"
[mcp_servers.docs]
command = "docs-server"
args = ["--stdio"]
env_vars = ["DOCS_TOKEN"]
[mcp_servers.docs.env]
STATIC = "yes"
"#,
)
.expect("parse expected config");
assert_eq!(config, expected_config);
let mcp_servers = config
.get("mcp_servers")
.cloned()
.ok_or_else(|| io::Error::other("missing mcp_servers"))
.expect("mcp servers");
let _supported_mcp_config: std::collections::HashMap<
String,
codex_config::types::McpServerConfig,
> = mcp_servers
.try_into()
.expect("migrated MCP config should be supported");
let hooks: JsonValue = serde_json::from_str(
&fs::read_to_string(repo_root.join(".codex").join("hooks.json")).expect("read hooks"),
)
.expect("parse hooks");
let _supported_hooks: codex_config::HooksFile =
serde_json::from_value(hooks.clone()).expect("migrated hooks should be supported");
assert_eq!(
hooks,
serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "echo external-agent",
"timeout": 3
}]
}],
"Stop": [{
"hooks": [{
"type": "command",
"command": "echo done"
}]
}]
}
})
);
assert!(
!repo_root
.join(".codex")
.join("hooks.migration-notes.md")
.exists()
);
assert_eq!(
fs::read_to_string(
repo_root
.join(".agents")
.join("skills")
.join("source-command-pr-review")
.join("SKILL.md")
)
.expect("read command skill"),
"---\nname: \"source-command-pr-review\"\ndescription: \"Review PR\"\n---\n\n# source-command-pr-review\n\nUse this skill when the user asks to run the migrated source command `pr-review`.\n\n## Command Template\n\nReview the pull request carefully.\n"
);
let agent: TomlValue = toml::from_str(
&fs::read_to_string(
repo_root
.join(".codex")
.join("agents")
.join("researcher.toml"),
)
.expect("read agent"),
)
.expect("parse agent");
let expected_agent: TomlValue = toml::from_str(
r#"
name = "researcher"
description = "Research role"
model_reasoning_effort = "high"
sandbox_mode = "workspace-write"
developer_instructions = """
Research with Codex carefully."""
"#,
)
.expect("parse expected agent");
assert_eq!(agent, expected_agent);
}
@@ -0,0 +1,508 @@
use super::super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn import_repo_agents_md_from_nested_cwd_rewrites_terms_and_skips_non_empty_targets() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo-a");
let nested_cwd = repo_root.join("nested");
let repo_with_existing_target = root.path().join("repo-b");
fs::create_dir_all(&nested_cwd).expect("create nested cwd");
fs::create_dir_all(repo_root.join(".git")).expect("create git");
fs::create_dir_all(repo_with_existing_target.join(".git")).expect("create git");
fs::write(
repo_root.join(EXTERNAL_AGENT_CONFIG_MD),
format!(
"{SOURCE_EXTERNAL_AGENT_PRODUCT_NAME}\n{SOURCE_EXTERNAL_AGENT_NAME}\n{SOURCE_EXTERNAL_AGENT_UPPER_PRODUCT_NAME}\nSee {EXTERNAL_AGENT_CONFIG_MD}\n"
),
)
.expect("write source");
fs::write(
repo_with_existing_target.join(EXTERNAL_AGENT_CONFIG_MD),
"new source",
)
.expect("write source");
fs::write(
repo_with_existing_target.join("AGENTS.md"),
"keep existing target",
)
.expect("write target");
let outcome = service_for_paths(
root.path().join(EXTERNAL_AGENT_DIR),
root.path().join(".codex"),
)
.import(vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(nested_cwd.clone()),
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(repo_with_existing_target.clone()),
details: None,
},
])
.await;
assert_eq!(
outcome.item_results,
vec![
ExternalAgentConfigImportItemResult {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(nested_cwd.clone()),
success_count: 1,
error_count: 0,
successes: vec![import_success(
ExternalAgentConfigMigrationItemType::AgentsMd,
Some(nested_cwd),
repo_root
.join(EXTERNAL_AGENT_CONFIG_MD)
.display()
.to_string(),
repo_root.join("AGENTS.md").display().to_string(),
)],
raw_errors: Vec::new(),
},
ExternalAgentConfigImportItemResult {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(repo_with_existing_target.clone()),
success_count: 0,
error_count: 0,
successes: Vec::new(),
raw_errors: Vec::new(),
},
]
);
assert_eq!(
fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"),
"Codex\nCodex\nCodex\nSee AGENTS.md\n"
);
assert_eq!(
fs::read_to_string(repo_with_existing_target.join("AGENTS.md"))
.expect("read existing target"),
"keep existing target"
);
}
#[tokio::test]
async fn import_repo_agents_md_overwrites_empty_targets() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git");
fs::write(
repo_root.join(EXTERNAL_AGENT_CONFIG_MD),
format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"),
)
.expect("write source");
fs::write(repo_root.join("AGENTS.md"), " \n\t").expect("write empty target");
let outcome = service_for_paths(
root.path().join(EXTERNAL_AGENT_DIR),
root.path().join(".codex"),
)
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
}])
.await;
assert_eq!(
outcome.item_results,
vec![ExternalAgentConfigImportItemResult {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(repo_root.clone()),
success_count: 1,
error_count: 0,
successes: vec![import_success(
ExternalAgentConfigMigrationItemType::AgentsMd,
Some(repo_root.clone()),
repo_root
.join(EXTERNAL_AGENT_CONFIG_MD)
.display()
.to_string(),
repo_root.join("AGENTS.md").display().to_string(),
)],
raw_errors: Vec::new(),
}]
);
assert_eq!(
fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"),
"Codex guidance"
);
}
#[tokio::test]
async fn detect_repo_prefers_non_empty_external_agent_agents_source() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git");
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir");
fs::write(repo_root.join(EXTERNAL_AGENT_CONFIG_MD), " \n\t").expect("write empty root source");
fs::write(
repo_root
.join(EXTERNAL_AGENT_DIR)
.join(EXTERNAL_AGENT_CONFIG_MD),
format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"),
)
.expect("write external agent source");
let items = service_for_paths(
root.path().join(EXTERNAL_AGENT_DIR),
root.path().join(".codex"),
)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
include_memory: false,
cwds: Some(vec![repo_root.clone()]),
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Migrate {} to {}",
repo_root
.join(EXTERNAL_AGENT_DIR)
.join(EXTERNAL_AGENT_CONFIG_MD)
.display(),
repo_root.join("AGENTS.md").display(),
),
cwd: Some(repo_root),
details: None,
}]
);
}
#[tokio::test]
async fn import_repo_hooks_preserves_disabled_codex_hooks_feature() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir");
fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir");
fs::write(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
r#"{"hooks":{"Stop":[{"hooks":[{"command":"echo done"}]}]}}"#,
)
.expect("write hooks");
fs::write(
repo_root.join(".codex").join("config.toml"),
"[features]\ncodex_hooks = false\n",
)
.expect("write config");
let outcome = service_for_paths(
root.path().join(EXTERNAL_AGENT_DIR),
root.path().join(".codex"),
)
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Hooks,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
}])
.await;
assert_eq!(
outcome.item_results,
vec![ExternalAgentConfigImportItemResult {
item_type: ExternalAgentConfigMigrationItemType::Hooks,
description: String::new(),
cwd: Some(repo_root.clone()),
success_count: 1,
error_count: 0,
successes: vec![import_success(
ExternalAgentConfigMigrationItemType::Hooks,
Some(repo_root.clone()),
"Stop",
"Stop",
)],
raw_errors: Vec::new(),
}]
);
assert_eq!(
fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
"[features]\ncodex_hooks = false\n"
);
let hooks: JsonValue = serde_json::from_str(
&fs::read_to_string(repo_root.join(".codex").join("hooks.json")).expect("read hooks"),
)
.expect("parse hooks");
assert_eq!(
hooks,
serde_json::json!({
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": "echo done"
}]
}]
}
})
);
}
#[tokio::test]
async fn import_repo_mcp_uses_home_settings_toggles_when_repo_settings_missing() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
r#"{"disabledMcpjsonServers":["blocked"]}"#,
)
.expect("write home settings");
fs::write(
root.path().join(EXTERNAL_AGENT_PROJECT_CONFIG_FILE),
serde_json::json!({
"projects": {
repo_root.display().to_string(): {
"mcpServers": {
"allowed": {"command": "allowed-server"},
"blocked": {"command": "blocked-server"}
}
}
}
})
.to_string(),
)
.expect("write external agent project config");
let outcome = service_for_paths(external_agent_home, root.path().join(".codex"))
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
}])
.await;
assert_eq!(
outcome.item_results,
vec![ExternalAgentConfigImportItemResult {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: String::new(),
cwd: Some(repo_root.clone()),
success_count: 1,
error_count: 0,
successes: vec![import_success(
ExternalAgentConfigMigrationItemType::McpServerConfig,
Some(repo_root.clone()),
"allowed",
"allowed",
)],
raw_errors: Vec::new(),
}]
);
let config: TomlValue = toml::from_str(
&fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
)
.expect("parse config");
let expected: TomlValue = toml::from_str(
r#"
[mcp_servers.allowed]
command = "allowed-server"
"#,
)
.expect("parse expected config");
assert_eq!(config, expected);
}
#[tokio::test]
async fn import_repo_mcp_uses_local_settings_toggles_over_project_settings() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir");
fs::write(
repo_root.join(".mcp.json"),
r#"{
"mcpServers": {
"project-disabled": {"command": "project-disabled-server"},
"local-disabled": {"command": "local-disabled-server"},
"local-enabled": {"command": "local-enabled-server"}
}
}"#,
)
.expect("write mcp");
fs::write(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
r#"{
"enabledMcpjsonServers": ["project-disabled", "local-disabled"],
"disabledMcpjsonServers": ["project-disabled"]
}"#,
)
.expect("write project settings");
fs::write(
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("settings.local.json"),
r#"{
"enabledMcpjsonServers": ["local-enabled", "local-disabled"],
"disabledMcpjsonServers": ["local-disabled"]
}"#,
)
.expect("write local settings");
service_for_paths(external_agent_home, root.path().join(".codex"))
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
}])
.await;
let config: TomlValue = toml::from_str(
&fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
)
.expect("parse config");
let expected: TomlValue = toml::from_str(
r#"
[mcp_servers.local-enabled]
command = "local-enabled-server"
"#,
)
.expect("parse expected config");
assert_eq!(config, expected);
}
#[tokio::test]
async fn import_repo_mcp_ignores_invalid_home_settings_when_repo_settings_missing() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(external_agent_home.join("settings.json"), "{ invalid json")
.expect("write invalid home settings");
fs::write(
root.path().join(EXTERNAL_AGENT_PROJECT_CONFIG_FILE),
serde_json::json!({
"projects": {
repo_root.display().to_string(): {
"mcpServers": {
"docs": {"command": "docs-server"}
}
}
}
})
.to_string(),
)
.expect("write external agent project config");
service_for_paths(external_agent_home, root.path().join(".codex"))
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
}])
.await;
let config: TomlValue = toml::from_str(
&fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
)
.expect("parse config");
let expected: TomlValue = toml::from_str(
r#"
[mcp_servers.docs]
command = "docs-server"
"#,
)
.expect("parse expected config");
assert_eq!(config, expected);
}
#[tokio::test]
async fn import_repo_uses_non_empty_external_agent_agents_source() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git");
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir");
fs::write(repo_root.join(EXTERNAL_AGENT_CONFIG_MD), "").expect("write empty root source");
fs::write(
repo_root
.join(EXTERNAL_AGENT_DIR)
.join(EXTERNAL_AGENT_CONFIG_MD),
format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"),
)
.expect("write external agent source");
service_for_paths(
root.path().join(EXTERNAL_AGENT_DIR),
root.path().join(".codex"),
)
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
}])
.await;
assert_eq!(
fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"),
"Codex guidance"
);
}
#[tokio::test]
async fn import_continues_after_failed_migration_item() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git");
fs::write(repo_root.join(EXTERNAL_AGENT_CONFIG_MD), "Claude guidance").expect("write source");
service_for_paths(
root.path().join(EXTERNAL_AGENT_DIR),
root.path().join(".codex"),
)
.import(vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: "invalid plugin migration".to_string(),
cwd: Some(repo_root.clone()),
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: "valid agents migration".to_string(),
cwd: Some(repo_root.clone()),
details: None,
},
])
.await;
assert_eq!(
fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"),
"Codex guidance"
);
}
#[test]
fn migration_metric_tags_for_skills_include_skills_count() {
assert_eq!(
migration_metric_tags(ExternalAgentConfigMigrationItemType::Skills, Some(3)),
vec![
("migration_type", "skills".to_string()),
("skills_count", "3".to_string()),
]
);
}
@@ -0,0 +1,39 @@
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn detect_does_not_offer_memory_for_an_unsupported_source() {
let root = TempDir::new().expect("create tempdir");
let external_agent_home = root.path().join(".cursor");
let codex_home = root.path().join(".codex");
let project_root = external_agent_home.join("projects/project-a");
let project_memory = project_root.join("memory");
let project_cwd = root.path().join("project-a-cwd");
fs::create_dir_all(&project_memory).expect("create project memory");
fs::create_dir_all(&project_cwd).expect("create project cwd");
fs::write(project_memory.join("MEMORY.md"), "project memory").expect("write project memory");
fs::write(
project_root.join("session.jsonl"),
serde_json::json!({
"type": "user",
"cwd": project_cwd,
"timestamp": "2026-07-13T00:00:00Z",
"message": { "content": "remember this" },
})
.to_string(),
)
.expect("write project session");
let mut service = service_for_paths(external_agent_home, codex_home);
service.source = ExternalAgentSource::Cur;
let items = service
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
include_memory: true,
cwds: None,
})
.await
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
@@ -0,0 +1,5 @@
#[path = "plugins/basics.rs"]
mod basics;
#[path = "plugins/marketplaces.rs"]
mod marketplaces;
@@ -0,0 +1,697 @@
use super::super::*;
use crate::migration_source::MarketplaceImportSource;
use crate::source_cla;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn detect_home_lists_enabled_plugins_from_settings() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true,
"deployer@acme-tools": true,
"analyzer@security-plugins": false
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/external-agent-plugins"
}
}
}"#,
)
.expect("write settings");
let items = service_for_paths(external_agent_home.clone(), codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
include_memory: false,
cwds: None,
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Migrate enabled plugins from {}",
external_agent_home.join("settings.json").display()
),
cwd: None,
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec!["deployer".to_string(), "formatter".to_string()],
}],
..Default::default()
}),
}]
);
}
#[tokio::test]
async fn detect_home_uses_materialized_known_marketplace_for_inline_npm_source() {
let (_root, external_agent_home, codex_home) = fixture_paths();
let marketplace_root = external_agent_home
.join("plugins")
.join("marketplaces")
.join("acme-tools");
fs::create_dir_all(external_agent_home.join("plugins"))
.expect("create external agent plugins dir");
fs::create_dir_all(&marketplace_root).expect("create installed marketplace dir");
fs::write(
external_agent_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": {
"source": "settings",
"name": "acme-tools",
"plugins": [{
"name": "formatter",
"source": {
"source": "npm",
"package": "@acme/formatter"
}
}]
}
}
}
}"#,
)
.expect("write settings");
fs::write(
external_agent_home.join(EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH),
serde_json::to_string_pretty(&serde_json::json!({
"acme-tools": {
"source": {
"source": "settings",
"name": "acme-tools",
"plugins": [{
"name": "formatter",
"source": {
"source": "npm",
"package": "@acme/formatter",
},
}],
},
"installLocation": "plugins/marketplaces/acme-tools",
"lastUpdated": "2026-07-09T00:16:23.611Z",
}
}))
.expect("serialize known marketplaces"),
)
.expect("write known marketplaces");
let items = service_for_paths(external_agent_home.clone(), codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
include_memory: false,
cwds: None,
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Migrate enabled plugins from {}",
external_agent_home.join("settings.json").display()
),
cwd: None,
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec!["formatter".to_string()],
}],
..Default::default()
}),
}]
);
}
#[test]
fn marketplace_import_sources_prefers_scoped_source_over_registry_name_collision() {
let (root, external_agent_home, _codex_home) = fixture_paths();
let source_root = root.path().join("repo");
let scoped_marketplace = source_root.join("repo-marketplace");
let cached_marketplace = external_agent_home.join("plugins/marketplaces/debug");
fs::create_dir_all(&scoped_marketplace).expect("create scoped marketplace");
fs::create_dir_all(&cached_marketplace).expect("create cached marketplace");
fs::write(
external_agent_home.join(EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH),
serde_json::to_string_pretty(&serde_json::json!({
"debug": {
"source": {
"source": "github",
"repo": "acme/global-marketplace",
},
"installLocation": cached_marketplace,
}
}))
.expect("serialize known marketplaces"),
)
.expect("write known marketplaces");
let settings = serde_json::json!({
"extraKnownMarketplaces": {
"debug": {
"source": {
"source": "directory",
"path": "./repo-marketplace",
}
}
}
});
let import_sources =
source_cla::marketplace_import_sources(&settings, &external_agent_home, &source_root);
assert_eq!(
import_sources.get("debug"),
Some(&MarketplaceImportSource {
source: source_root.join("./repo-marketplace").display().to_string(),
ref_name: None,
})
);
}
#[test]
fn marketplace_import_sources_prefers_supported_declaration_over_materialization() {
let (_root, external_agent_home, _codex_home) = fixture_paths();
let cached_marketplace = external_agent_home.join("plugins/marketplaces/acme-tools");
fs::create_dir_all(&cached_marketplace).expect("create cached marketplace");
fs::write(
external_agent_home.join(EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH),
serde_json::to_string_pretty(&serde_json::json!({
"acme-tools": {
"source": {
"source": "git",
"url": "https://git.example.com/acme/tools.git",
"ref": "release",
},
"installLocation": cached_marketplace,
}
}))
.expect("serialize known marketplaces"),
)
.expect("write known marketplaces");
let import_sources = source_cla::marketplace_import_sources(
&serde_json::json!({}),
&external_agent_home,
&external_agent_home,
);
assert_eq!(
import_sources.get("acme-tools"),
Some(&MarketplaceImportSource {
source: "https://git.example.com/acme/tools.git".to_string(),
ref_name: Some("release".to_string()),
})
);
}
#[tokio::test]
async fn detect_home_plugins_uses_local_settings_over_project_settings() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true,
"legacy@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/external-agent-plugins"
}
}
}"#,
)
.expect("write project settings");
fs::write(
external_agent_home.join("settings.local.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": false,
"deployer@acme-tools": true
}
}"#,
)
.expect("write local settings");
let items = service_for_paths(external_agent_home.clone(), codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
include_memory: false,
cwds: None,
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Migrate enabled plugins from {}",
external_agent_home.join("settings.json").display()
),
cwd: None,
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec!["deployer".to_string(), "legacy".to_string()],
}],
..Default::default()
}),
}]
);
}
#[tokio::test]
async fn detect_repo_skips_plugins_that_are_already_configured_in_codex() {
let root = TempDir::new().expect("create tempdir");
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true,
"deployer@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/external-agent-plugins"
}
}
}"#,
)
.expect("write repo settings");
fs::write(
codex_home.join("config.toml"),
r#"
[plugins."formatter@acme-tools"]
enabled = true
"#,
)
.expect("write codex config");
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
include_memory: false,
cwds: Some(vec![repo_root.clone()]),
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Migrate enabled plugins from {}",
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("settings.json")
.display()
),
cwd: Some(repo_root),
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec!["deployer".to_string()],
}],
..Default::default()
}),
}]
);
}
#[tokio::test]
async fn detect_repo_skips_plugins_that_are_disabled_in_codex() {
let root = TempDir::new().expect("create tempdir");
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/external-agent-plugins"
}
}
}"#,
)
.expect("write repo settings");
fs::write(
codex_home.join("config.toml"),
r#"
[plugins."formatter@acme-tools"]
enabled = false
"#,
)
.expect("write codex config");
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
include_memory: false,
cwds: Some(vec![repo_root]),
})
.await
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[tokio::test]
async fn detect_repo_skips_plugins_without_explicit_enabled_in_codex() {
let root = TempDir::new().expect("create tempdir");
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/external-agent-plugins"
}
}
}"#,
)
.expect("write repo settings");
fs::write(
codex_home.join("config.toml"),
r#"
[plugins."formatter@acme-tools"]
"#,
)
.expect("write codex config");
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
include_memory: false,
cwds: Some(vec![repo_root]),
})
.await
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[tokio::test]
async fn import_plugins_requires_details() {
let (_root, external_agent_home, codex_home) = fixture_paths();
let err = service_for_paths(external_agent_home, codex_home)
.import_plugins(/*cwd*/ None, /*details*/ None)
.await
.expect_err("expected missing details error");
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
assert_eq!(err.to_string(), "plugins migration item is missing details");
}
#[tokio::test]
async fn detect_repo_does_not_skip_plugins_only_configured_in_project_codex() {
let root = TempDir::new().expect("create tempdir");
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir");
fs::create_dir_all(repo_root.join(".codex")).expect("create repo codex dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/external-agent-plugins"
}
}
}"#,
)
.expect("write repo settings");
fs::write(
repo_root.join(".codex").join("config.toml"),
r#"
[plugins."formatter@acme-tools"]
enabled = true
"#,
)
.expect("write project codex config");
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
include_memory: false,
cwds: Some(vec![repo_root.clone()]),
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Migrate enabled plugins from {}",
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("settings.json")
.display()
),
cwd: Some(repo_root),
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec!["formatter".to_string()],
}],
..Default::default()
}),
}]
);
}
#[tokio::test]
async fn detect_home_skips_plugins_without_marketplace_source() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
}
}"#,
)
.expect("write settings");
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
include_memory: false,
cwds: None,
})
.await
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[tokio::test]
async fn detect_home_skips_plugins_with_invalid_marketplace_source() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "github"
}
}
}"#,
)
.expect("write settings");
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
include_memory: false,
cwds: None,
})
.await
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[tokio::test]
async fn detect_repo_filters_plugins_against_installed_marketplace() {
let root = TempDir::new().expect("create tempdir");
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
let marketplace_root = codex_home.join(".tmp").join("marketplaces").join("debug");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir");
fs::create_dir_all(marketplace_root.join(".agents").join("plugins"))
.expect("create marketplace manifest dir");
fs::create_dir_all(
marketplace_root
.join("plugins")
.join("sample")
.join(".codex-plugin"),
)
.expect("create sample plugin");
fs::create_dir_all(
marketplace_root
.join("plugins")
.join("available")
.join(".codex-plugin"),
)
.expect("create available plugin");
fs::write(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
r#"{
"enabledPlugins": {
"sample@debug": true,
"available@debug": true,
"missing@debug": true
},
"extraKnownMarketplaces": {
"debug": {
"source": "owner/debug-marketplace"
}
}
}"#,
)
.expect("write repo settings");
fs::write(
codex_home.join("config.toml"),
r#"
[marketplaces.debug]
source_type = "git"
source = "owner/debug-marketplace"
"#,
)
.expect("write codex config");
fs::write(
marketplace_root
.join(".agents")
.join("plugins")
.join("marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
},
"policy": {
"installation": "NOT_AVAILABLE"
}
},
{
"name": "available",
"source": {
"source": "local",
"path": "./plugins/available"
}
}
]
}"#,
)
.expect("write marketplace manifest");
fs::write(
marketplace_root
.join("plugins")
.join("sample")
.join(".codex-plugin")
.join("plugin.json"),
r#"{"name":"sample"}"#,
)
.expect("write sample plugin manifest");
fs::write(
marketplace_root
.join("plugins")
.join("available")
.join(".codex-plugin")
.join("plugin.json"),
r#"{"name":"available"}"#,
)
.expect("write available plugin manifest");
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
include_memory: false,
cwds: Some(vec![repo_root.clone()]),
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Migrate enabled plugins from {}",
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("settings.json")
.display()
),
cwd: Some(repo_root),
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "debug".to_string(),
plugin_names: vec!["available".to_string()],
}],
..Default::default()
}),
}]
);
}
@@ -0,0 +1,712 @@
use super::super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn import_plugins_requires_source_marketplace_details() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "github",
"repo": "acme-corp/external-agent-plugins"
}
}
}"#,
)
.expect("write settings");
let outcome = service_for_paths(external_agent_home, codex_home)
.import_plugins(
/*cwd*/ None,
Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "other-tools".to_string(),
plugin_names: github_plugin_details().plugins[0].plugin_names.clone(),
}],
..Default::default()
}),
)
.await
.expect("import plugins");
assert_eq!(outcome.succeeded_marketplaces, Vec::<String>::new());
assert_eq!(outcome.succeeded_plugin_ids, Vec::<String>::new());
assert_eq!(outcome.failed_marketplaces, vec!["other-tools".to_string()]);
assert_eq!(
outcome.failed_plugin_ids,
vec!["formatter@other-tools".to_string()]
);
assert_single_plugin_raw_error(
&outcome.raw_errors,
"plugin_import",
"formatter@other-tools",
/*error_type*/ None,
);
}
#[tokio::test]
async fn import_plugins_defers_marketplace_source_validation_to_add_marketplace() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "local",
"path": "./external_plugins/acme-tools"
}
}
}"#,
)
.expect("write settings");
let outcome = service_for_paths(external_agent_home, codex_home)
.import_plugins(/*cwd*/ None, Some(github_plugin_details()))
.await
.expect("import plugins");
assert_eq!(outcome.succeeded_marketplaces, Vec::<String>::new());
assert_eq!(outcome.succeeded_plugin_ids, Vec::<String>::new());
assert_eq!(outcome.failed_marketplaces, vec!["acme-tools".to_string()]);
assert_eq!(
outcome.failed_plugin_ids,
vec!["formatter@acme-tools".to_string()]
);
assert_single_plugin_raw_error(
&outcome.raw_errors,
"plugin_import",
"formatter@acme-tools",
/*error_type*/ None,
);
}
#[tokio::test]
async fn import_plugins_supports_external_agent_plugin_marketplace_layout() {
let (_root, external_agent_home, codex_home) = fixture_paths();
let marketplace_root = external_agent_home.join("my-marketplace");
let plugin_root = marketplace_root.join("plugins").join("cloudflare");
fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR))
.expect("create marketplace manifest dir");
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
external_agent_home.join("settings.json"),
serde_json::to_string_pretty(&serde_json::json!({
"enabledPlugins": {
"cloudflare@my-plugins": true
},
"extraKnownMarketplaces": {
"my-plugins": {
"source": "local",
"path": marketplace_root
}
}
}))
.expect("serialize settings"),
)
.expect("write settings");
fs::write(
marketplace_root
.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)
.join("marketplace.json"),
r#"{
"name": "my-plugins",
"plugins": [
{
"name": "cloudflare",
"source": "./plugins/cloudflare"
}
]
}"#,
)
.expect("write marketplace manifest");
fs::write(
plugin_root.join(".codex-plugin").join("plugin.json"),
r#"{"name":"cloudflare","version":"0.1.0"}"#,
)
.expect("write plugin manifest");
let outcome = service_for_paths(external_agent_home, codex_home.clone())
.import_plugins(
/*cwd*/ None,
Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "my-plugins".to_string(),
plugin_names: vec!["cloudflare".to_string()],
}],
..Default::default()
}),
)
.await
.expect("import plugins");
assert_eq!(
outcome,
PluginImportOutcome {
succeeded_marketplaces: vec!["my-plugins".to_string()],
succeeded_plugin_ids: vec!["cloudflare@my-plugins".to_string()],
failed_marketplaces: Vec::new(),
failed_plugin_ids: Vec::new(),
raw_errors: Vec::new(),
}
);
let config = fs::read_to_string(codex_home.join("config.toml")).expect("read config");
assert!(config.contains(r#"[plugins."cloudflare@my-plugins"]"#));
assert!(config.contains("enabled = true"));
}
#[tokio::test]
async fn import_plugins_reuses_configured_marketplace_with_different_source() {
let (_root, external_agent_home, codex_home) = fixture_paths();
let configured_marketplace_root = external_agent_home.join("configured-marketplace");
let source_marketplace_root = external_agent_home.join("source-marketplace");
let configured_plugin_root = configured_marketplace_root.join("plugins/cloudflare");
let source_plugin_root = source_marketplace_root.join("plugins/cloudflare");
fs::create_dir_all(configured_marketplace_root.join(".agents/plugins"))
.expect("create configured marketplace manifest dir");
fs::create_dir_all(configured_plugin_root.join(".codex-plugin"))
.expect("create configured plugin manifest dir");
fs::create_dir_all(source_marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR))
.expect("create source marketplace manifest dir");
fs::create_dir_all(source_plugin_root.join(".codex-plugin"))
.expect("create source plugin manifest dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
external_agent_home.join("settings.json"),
serde_json::to_string_pretty(&serde_json::json!({
"enabledPlugins": {
"cloudflare@my-plugins": true
},
"extraKnownMarketplaces": {
"my-plugins": {
"source": "local",
"path": source_marketplace_root
}
}
}))
.expect("serialize settings"),
)
.expect("write settings");
fs::write(
codex_home.join("config.toml"),
format!(
r#"[marketplaces.my-plugins]
source_type = "local"
source = {configured_marketplace_root:?}
"#
),
)
.expect("write Codex config");
fs::write(
configured_marketplace_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "my-plugins",
"plugins": [{
"name": "cloudflare",
"source": {"source": "local", "path": "./plugins/cloudflare"}
}]
}"#,
)
.expect("write configured marketplace manifest");
fs::write(
source_marketplace_root
.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)
.join("marketplace.json"),
r#"{
"name": "my-plugins",
"plugins": [{"name": "cloudflare", "source": "./plugins/cloudflare"}]
}"#,
)
.expect("write source marketplace manifest");
fs::write(
configured_plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"cloudflare","version":"0.1.0"}"#,
)
.expect("write configured plugin manifest");
fs::write(
source_plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"cloudflare","version":"0.2.0"}"#,
)
.expect("write source plugin manifest");
let outcome = service_for_paths(external_agent_home, codex_home.clone())
.import_plugins(
/*cwd*/ None,
Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "my-plugins".to_string(),
plugin_names: vec!["cloudflare".to_string()],
}],
..Default::default()
}),
)
.await
.expect("import plugins");
assert_eq!(
outcome,
PluginImportOutcome {
succeeded_marketplaces: vec!["my-plugins".to_string()],
succeeded_plugin_ids: vec!["cloudflare@my-plugins".to_string()],
failed_marketplaces: Vec::new(),
failed_plugin_ids: Vec::new(),
raw_errors: Vec::new(),
}
);
let config: TomlValue =
toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config"))
.expect("parse config");
let expected: TomlValue = toml::from_str(&format!(
r#"[marketplaces.my-plugins]
source_type = "local"
source = {configured_marketplace_root:?}
[plugins."cloudflare@my-plugins"]
enabled = true
"#
))
.expect("parse expected config");
assert_eq!(config, expected);
}
#[tokio::test]
async fn detect_home_supports_relative_external_agent_plugin_marketplace_path() {
let (_root, external_agent_home, codex_home) = fixture_paths();
let marketplace_root = external_agent_home.join("my-marketplace");
let plugin_root = marketplace_root.join("plugins").join("cloudflare");
fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR))
.expect("create marketplace manifest dir");
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
external_agent_home.join("settings.json"),
r#"{
"enabledPlugins": {
"cloudflare@my-plugins": true
},
"extraKnownMarketplaces": {
"my-plugins": {
"source": "directory",
"path": "./my-marketplace"
}
}
}"#,
)
.expect("write settings");
fs::write(
marketplace_root
.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)
.join("marketplace.json"),
r#"{
"name": "my-plugins",
"plugins": [
{
"name": "cloudflare",
"source": "./plugins/cloudflare"
}
]
}"#,
)
.expect("write marketplace manifest");
fs::write(
plugin_root.join(".codex-plugin").join("plugin.json"),
r#"{"name":"cloudflare","version":"0.1.0"}"#,
)
.expect("write plugin manifest");
let items = service_for_paths(external_agent_home.clone(), codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
include_memory: false,
cwds: None,
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Migrate enabled plugins from {}",
external_agent_home.join("settings.json").display()
),
cwd: None,
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "my-plugins".to_string(),
plugin_names: vec!["cloudflare".to_string()],
}],
..Default::default()
}),
}]
);
}
#[tokio::test]
async fn detect_home_infers_external_official_marketplace_when_missing_from_settings() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
external_agent_home.join("settings.json"),
format!(
r#"{{
"enabledPlugins": {{
"sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}": true
}}
}}"#
),
)
.expect("write settings");
let items = service_for_paths(external_agent_home.clone(), codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
include_memory: false,
cwds: None,
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Migrate enabled plugins from {}",
external_agent_home.join("settings.json").display()
),
cwd: None,
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string(),
plugin_names: vec!["sample".to_string()],
}],
..Default::default()
}),
}]
);
}
#[tokio::test]
async fn import_plugins_supports_relative_external_agent_plugin_marketplace_path() {
let (_root, external_agent_home, codex_home) = fixture_paths();
let marketplace_root = external_agent_home.join("my-marketplace");
let plugin_root = marketplace_root.join("plugins").join("cloudflare");
fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR))
.expect("create marketplace manifest dir");
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
external_agent_home.join("settings.json"),
r#"{
"enabledPlugins": {
"cloudflare@my-plugins": true
},
"extraKnownMarketplaces": {
"my-plugins": {
"source": "directory",
"path": "./my-marketplace"
}
}
}"#,
)
.expect("write settings");
fs::write(
marketplace_root
.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)
.join("marketplace.json"),
r#"{
"name": "my-plugins",
"plugins": [
{
"name": "cloudflare",
"source": "./plugins/cloudflare"
}
]
}"#,
)
.expect("write marketplace manifest");
fs::write(
plugin_root.join(".codex-plugin").join("plugin.json"),
r#"{"name":"cloudflare","version":"0.1.0"}"#,
)
.expect("write plugin manifest");
let outcome = service_for_paths(external_agent_home, codex_home.clone())
.import_plugins(
/*cwd*/ None,
Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "my-plugins".to_string(),
plugin_names: vec!["cloudflare".to_string()],
}],
..Default::default()
}),
)
.await
.expect("import plugins");
assert_eq!(
outcome,
PluginImportOutcome {
succeeded_marketplaces: vec!["my-plugins".to_string()],
succeeded_plugin_ids: vec!["cloudflare@my-plugins".to_string()],
failed_marketplaces: Vec::new(),
failed_plugin_ids: Vec::new(),
raw_errors: Vec::new(),
}
);
let config = fs::read_to_string(codex_home.join("config.toml")).expect("read config");
assert!(config.contains(r#"[plugins."cloudflare@my-plugins"]"#));
assert!(config.contains("enabled = true"));
}
#[tokio::test]
async fn import_plugins_infers_external_official_marketplace_when_missing_from_settings() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
external_agent_home.join("settings.json"),
format!(
r#"{{
"enabledPlugins": {{
"sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}": true
}}
}}"#
),
)
.expect("write settings");
let outcome = service_for_paths(external_agent_home, codex_home)
.import_plugins(
/*cwd*/ None,
Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string(),
plugin_names: vec!["sample".to_string()],
}],
..Default::default()
}),
)
.await
.expect("import plugins");
assert_eq!(
outcome.succeeded_marketplaces,
vec![EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string()]
);
assert_eq!(outcome.succeeded_plugin_ids, Vec::<String>::new());
assert_eq!(outcome.failed_marketplaces, Vec::<String>::new());
assert_eq!(
outcome.failed_plugin_ids,
vec![format!("sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}")]
);
assert_single_plugin_raw_error(
&outcome.raw_errors,
"plugin_import",
&format!("sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}"),
Some("plugin_not_found"),
);
}
#[tokio::test]
async fn detect_repo_supports_project_relative_external_agent_plugin_marketplace_path() {
let root = TempDir::new().expect("create tempdir");
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
let marketplace_root = repo_root.join("my-marketplace");
let plugin_root = marketplace_root.join("plugins").join("cloudflare");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir");
fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR))
.expect("create marketplace manifest dir");
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
r#"{
"enabledPlugins": {
"cloudflare@my-plugins": true
},
"extraKnownMarketplaces": {
"my-plugins": {
"source": "directory",
"path": "./my-marketplace"
}
}
}"#,
)
.expect("write settings");
fs::write(
marketplace_root
.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)
.join("marketplace.json"),
r#"{
"name": "my-plugins",
"plugins": [
{
"name": "cloudflare",
"source": "./plugins/cloudflare"
}
]
}"#,
)
.expect("write marketplace manifest");
fs::write(
plugin_root.join(".codex-plugin").join("plugin.json"),
r#"{"name":"cloudflare","version":"0.1.0"}"#,
)
.expect("write plugin manifest");
let items = service_for_paths(external_agent_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
include_memory: false,
cwds: Some(vec![repo_root.clone()]),
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Migrate enabled plugins from {}",
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("settings.json")
.display()
),
cwd: Some(repo_root),
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "my-plugins".to_string(),
plugin_names: vec!["cloudflare".to_string()],
}],
..Default::default()
}),
}]
);
}
#[tokio::test]
async fn import_plugins_supports_project_relative_external_agent_plugin_marketplace_path() {
let root = TempDir::new().expect("create tempdir");
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
let codex_home = root.path().join(".codex");
let repo_root = root.path().join("repo");
let marketplace_root = repo_root.join("my-marketplace");
let plugin_root = marketplace_root.join("plugins").join("cloudflare");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir");
fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR))
.expect("create marketplace manifest dir");
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
r#"{
"enabledPlugins": {
"cloudflare@my-plugins": true
},
"extraKnownMarketplaces": {
"my-plugins": {
"source": "directory",
"path": "./my-marketplace"
}
}
}"#,
)
.expect("write settings");
fs::write(
marketplace_root
.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)
.join("marketplace.json"),
r#"{
"name": "my-plugins",
"plugins": [
{
"name": "cloudflare",
"source": "./plugins/cloudflare"
}
]
}"#,
)
.expect("write marketplace manifest");
fs::write(
plugin_root.join(".codex-plugin").join("plugin.json"),
r#"{"name":"cloudflare","version":"0.1.0"}"#,
)
.expect("write plugin manifest");
let outcome = service_for_paths(external_agent_home, codex_home.clone())
.import_plugins(
Some(repo_root.as_path()),
Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "my-plugins".to_string(),
plugin_names: vec!["cloudflare".to_string()],
}],
..Default::default()
}),
)
.await
.expect("import plugins");
assert_eq!(
outcome,
PluginImportOutcome {
succeeded_marketplaces: vec!["my-plugins".to_string()],
succeeded_plugin_ids: vec!["cloudflare@my-plugins".to_string()],
failed_marketplaces: Vec::new(),
failed_plugin_ids: Vec::new(),
raw_errors: Vec::new(),
}
);
let config = fs::read_to_string(codex_home.join("config.toml")).expect("read config");
assert!(config.contains(r#"[plugins."cloudflare@my-plugins"]"#));
assert!(config.contains("enabled = true"));
}
#[test]
fn import_skills_returns_only_new_skill_directory_names() {
let (_root, external_agent_home, codex_home) = fixture_paths();
let agents_skills = codex_home
.parent()
.map(|parent| parent.join(".agents").join("skills"))
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
fs::create_dir_all(external_agent_home.join("skills").join("skill-a"))
.expect("create source a");
fs::create_dir_all(external_agent_home.join("skills").join("skill-b"))
.expect("create source b");
fs::create_dir_all(agents_skills.join("skill-a")).expect("create existing target");
let copied_names = service_for_paths(external_agent_home, codex_home)
.import_skills(/*cwd*/ None)
.expect("import skills");
assert_eq!(copied_names, vec!["skill-b".to_string()]);
}
@@ -17,7 +17,7 @@ const SESSION_IMPORT_LEDGER_FILE: &str = "external_agent_session_imports.json";
const SESSION_HASH_BUFFER_SIZE: usize = 64 * 1024;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(super) struct ImportedExternalAgentSessionLedger {
pub(crate) struct ImportedExternalAgentSessionLedger {
records: Vec<ImportedExternalAgentSessionRecord>,
}
@@ -48,7 +48,7 @@ pub struct ImportedConnectorCandidate {
}
#[derive(Debug, Clone, Copy)]
pub(super) struct ImportedSourceState {
pub(crate) struct ImportedSourceState {
pub source_modified_at: Option<i64>,
pub imported_at: i64,
}
@@ -144,7 +144,7 @@ pub fn read_imported_connector_candidates(
}
impl ImportedExternalAgentSessionLedger {
pub(super) fn source_states(&self) -> HashMap<&Path, ImportedSourceState> {
pub(crate) fn source_states(&self) -> HashMap<&Path, ImportedSourceState> {
let mut states = HashMap::new();
for record in &self.records {
states.insert(
@@ -158,7 +158,7 @@ impl ImportedExternalAgentSessionLedger {
states
}
pub(super) fn contains_current_source(&self, source_path: &Path) -> io::Result<bool> {
pub(crate) fn contains_current_source(&self, source_path: &Path) -> io::Result<bool> {
if self.records.is_empty() {
return Ok(false);
}
@@ -176,7 +176,7 @@ impl ImportedExternalAgentSessionLedger {
}))
}
pub(super) fn refresh_current_source(
pub(crate) fn refresh_current_source(
&mut self,
source_path: &Path,
source_modified_at: i64,
@@ -203,7 +203,7 @@ impl ImportedExternalAgentSessionLedger {
}
}
pub(super) fn load_import_ledger(
pub(crate) fn load_import_ledger(
codex_home: &Path,
) -> io::Result<ImportedExternalAgentSessionLedger> {
let path = import_ledger_path(codex_home);
@@ -222,7 +222,7 @@ pub(super) fn load_import_ledger(
})
}
pub(super) fn save_import_ledger(
pub(crate) fn save_import_ledger(
codex_home: &Path,
ledger: &ImportedExternalAgentSessionLedger,
) -> io::Result<()> {
@@ -1,11 +1,8 @@
//! Parsing and export helpers for external-agent session histories.
mod connectors_cla;
mod detect_cla;
mod detect_cur;
mod export;
mod ledger;
mod records;
pub(crate) mod ledger;
pub(crate) mod records;
mod title;
use codex_protocol::protocol::RolloutItem;
@@ -14,10 +11,10 @@ use std::io;
use std::path::Path;
use std::path::PathBuf;
pub use connectors_cla::ImportedSessionConnectorAttribution;
pub use connectors_cla::detect_imported_cla_session_connectors;
pub use detect_cla::detect_recent_cla_sessions;
pub use detect_cur::detect_recent_cur_sessions;
pub use crate::detect::sessions::ImportedSessionConnectorAttribution;
pub use crate::detect::sessions::detect_imported_cla_session_connectors;
pub use crate::detect::sessions::detect_recent_cla_sessions;
pub use crate::detect::sessions::detect_recent_cur_sessions;
use export::load_session_for_import_with_content_sha256;
pub use ledger::CompletedExternalAgentSessionImport;
pub use ledger::ImportedConnectorCandidate;
@@ -29,7 +26,7 @@ pub use records::summarize_session;
const SESSION_TITLE_MAX_LEN: usize = 120;
fn normalized_connector_display_name(name: Option<&str>) -> Option<String> {
pub(crate) fn normalized_connector_display_name(name: Option<&str>) -> Option<String> {
name.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToOwned::to_owned)
@@ -145,7 +142,7 @@ fn truncate(text: &str, max_len: usize) -> String {
format!("{prefix}...")
}
fn now_unix_seconds() -> i64 {
pub(crate) fn now_unix_seconds() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
@@ -0,0 +1,181 @@
use super::InstructionSourceGroup;
use super::build_config;
use super::is_non_empty_text_file;
use super::read_json_file;
use crate::RewriteProfile;
use crate::build_mcp_config_from_external;
use crate::hook_migration_event_names_cla;
use crate::import_hooks_cla;
use crate::import_subagents_with_rewrite_profile;
use serde_json::Value as JsonValue;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
pub struct ClaSource;
impl ClaSource {
pub const CONFIG_DIR: &'static str = ".claude";
pub const CONFIG_MD: &'static str = "CLAUDE.md";
pub const SETTINGS_FILE: &'static str = "settings.json";
pub const REWRITE_PROFILE: RewriteProfile = RewriteProfile::new(
Self::CONFIG_MD,
&[
"claude code",
"claude-code",
"claude_code",
"claudecode",
"claude",
],
);
pub fn connector_metadata_roots(external_agent_home: &Path) -> Vec<PathBuf> {
let Some(home) = external_agent_home
.parent()
.filter(|path| !path.as_os_str().is_empty())
else {
return Vec::new();
};
#[cfg(target_os = "macos")]
{
vec![home.join("Library/Application Support/Claude")]
}
#[cfg(target_os = "windows")]
{
let default_roaming = home.join("AppData/Roaming");
let default_local = home.join("AppData/Local");
let roaming = std::env::var_os("APPDATA")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| default_roaming.clone());
let local = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| default_local.clone());
let mut roots = vec![
local.join("Packages/Claude_pzs8sxrjxfjjc/LocalCache/Roaming/Claude"),
roaming.join("Claude"),
default_local.join("Packages/Claude_pzs8sxrjxfjjc/LocalCache/Roaming/Claude"),
default_roaming.join("Claude"),
];
roots.sort();
roots.dedup();
roots
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
vec![home.join(".config/Claude")]
}
}
pub fn effective_settings(project_settings: &Path) -> io::Result<Option<JsonValue>> {
let mut effective = read_json_file(project_settings)?;
let Some(settings_dir) = project_settings.parent() else {
return Ok(effective);
};
let local_settings = match read_json_file(&settings_dir.join("settings.local.json")) {
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)
}
pub fn build_config(settings: &JsonValue) -> io::Result<TomlValue> {
build_config(settings, Self::append_config)
}
pub fn append_config(
root: &mut toml::map::Map<String, TomlValue>,
settings: &serde_json::Map<String, JsonValue>,
) {
if settings
.get("sandbox")
.and_then(JsonValue::as_object)
.and_then(|sandbox| sandbox.get("enabled"))
.and_then(JsonValue::as_bool)
== Some(true)
{
root.insert(
"sandbox_mode".to_string(),
TomlValue::String("workspace-write".to_string()),
);
}
}
pub fn build_mcp_config(
source_root: &Path,
external_agent_home: &Path,
settings: Option<&JsonValue>,
) -> io::Result<TomlValue> {
build_mcp_config_from_external(source_root, Some(external_agent_home), settings)
}
pub fn repo_instruction_source_groups(
repo_root: &Path,
) -> io::Result<Vec<InstructionSourceGroup>> {
for candidate in [
repo_root.join(Self::CONFIG_MD),
repo_root.join(Self::CONFIG_DIR).join(Self::CONFIG_MD),
] {
if is_non_empty_text_file(&candidate)? {
return Ok(vec![InstructionSourceGroup {
scope: repo_root.to_path_buf(),
sources: vec![candidate],
}]);
}
}
Ok(Vec::new())
}
pub fn home_instruction_sources(external_agent_home: &Path) -> io::Result<Vec<PathBuf>> {
let path = external_agent_home.join(Self::CONFIG_MD);
Ok(is_non_empty_text_file(&path)?
.then_some(path)
.into_iter()
.collect())
}
pub fn read_instruction_source(path: &Path) -> io::Result<String> {
fs::read_to_string(path)
}
pub fn import_subagents(source_agents: &Path, target_agents: &Path) -> io::Result<Vec<String>> {
import_subagents_with_rewrite_profile(source_agents, target_agents, Self::REWRITE_PROFILE)
}
pub fn hook_event_names(source_dir: &Path, target_hooks: &Path) -> io::Result<Vec<String>> {
hook_migration_event_names_cla(source_dir, target_hooks, Self::REWRITE_PROFILE)
}
pub fn import_hooks(source_dir: &Path, target_hooks: &Path) -> io::Result<bool> {
import_hooks_cla(source_dir, target_hooks, Self::REWRITE_PROFILE)
}
}
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(),
}
}
@@ -0,0 +1,160 @@
use super::InstructionSourceGroup;
use super::build_config;
use super::is_non_empty_text_file;
use super::read_json_file;
use crate::RewriteProfile;
use crate::build_mcp_config_from_json_file;
use crate::hook_migration_event_names_cur;
use crate::import_hooks_cur;
use crate::import_subagents_with_rewrite_profile;
use crate::invalid_data_error;
use serde_json::Value as JsonValue;
use std::fs;
use std::io;
use std::path::Path;
use toml::Value as TomlValue;
pub struct CurSource;
impl CurSource {
pub const CONFIG_DIR: &'static str = ".cursor";
pub const MIGRATION_SOURCE: &'static str = "cursor";
pub const LEGACY_RULES_FILE: &'static str = ".cursorrules";
pub const HOME_CONFIG_FILE: &'static str = "cli-config.json";
pub const PROJECT_CONFIG_FILE: &'static str = "cli.json";
pub const SANDBOX_CONFIG_FILE: &'static str = "sandbox.json";
pub const HOOKS_CONFIG_FILE: &'static str = "hooks.json";
pub const SANDBOX_SETTINGS_KEY: &'static str = "__cursorSandbox";
pub const REWRITE_PROFILE: RewriteProfile = RewriteProfile::new(Self::LEGACY_RULES_FILE, &[])
.with_case_sensitive_term_variants(&["Cursor"]);
pub fn effective_settings(
source_dir: &Path,
source_settings: &Path,
) -> io::Result<Option<JsonValue>> {
let mut effective = read_json_file(source_settings)?;
let sandbox_settings = read_json_file(&source_dir.join(Self::SANDBOX_CONFIG_FILE))?;
if let Some(sandbox_settings) = sandbox_settings {
let effective =
effective.get_or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
let Some(effective) = effective.as_object_mut() else {
return Err(invalid_data_error(
"external agent settings root must be an object",
));
};
effective.insert(Self::SANDBOX_SETTINGS_KEY.to_string(), sandbox_settings);
}
Ok(effective)
}
pub fn build_config(settings: &JsonValue) -> io::Result<TomlValue> {
build_config(settings, Self::append_config)
}
pub fn append_config(
root: &mut toml::map::Map<String, TomlValue>,
settings: &serde_json::Map<String, JsonValue>,
) {
let Some(sandbox) = settings
.get(Self::SANDBOX_SETTINGS_KEY)
.and_then(JsonValue::as_object)
else {
return;
};
let sandbox_mode = match sandbox.get("type").and_then(JsonValue::as_str) {
Some("workspace_readwrite") => Some("workspace-write"),
Some("read_only") => Some("read-only"),
_ => None,
};
if let Some(sandbox_mode) = sandbox_mode {
root.insert(
"sandbox_mode".to_string(),
TomlValue::String(sandbox_mode.to_string()),
);
}
if sandbox_mode != Some("workspace-write") {
return;
}
let mut workspace_write = toml::map::Map::new();
if let Some(paths) = sandbox
.get("additionalReadwritePaths")
.and_then(JsonValue::as_array)
{
let paths = paths
.iter()
.filter_map(JsonValue::as_str)
.filter(|path| Path::new(path).is_absolute())
.map(|path| TomlValue::String(path.to_string()))
.collect::<Vec<_>>();
if !paths.is_empty() {
workspace_write.insert("writable_roots".to_string(), TomlValue::Array(paths));
}
}
if sandbox.get("disableTmpWrite").and_then(JsonValue::as_bool) == Some(true) {
workspace_write.insert("exclude_slash_tmp".to_string(), TomlValue::Boolean(true));
workspace_write.insert(
"exclude_tmpdir_env_var".to_string(),
TomlValue::Boolean(true),
);
}
if sandbox
.get("networkPolicy")
.and_then(JsonValue::as_object)
.and_then(|network| network.get("default"))
.and_then(JsonValue::as_str)
== Some("allow")
{
workspace_write.insert("network_access".to_string(), TomlValue::Boolean(true));
}
if !workspace_write.is_empty() {
root.insert(
"sandbox_workspace_write".to_string(),
TomlValue::Table(workspace_write),
);
}
}
pub fn build_mcp_config(source_dir: &Path) -> io::Result<TomlValue> {
build_mcp_config_from_json_file(&source_dir.join("mcp.json"))
}
pub fn repo_instruction_source_groups(
repo_root: &Path,
) -> io::Result<Vec<InstructionSourceGroup>> {
let source = repo_root.join(Self::LEGACY_RULES_FILE);
Ok(is_non_empty_text_file(&source)?
.then(|| InstructionSourceGroup {
scope: repo_root.to_path_buf(),
sources: vec![source],
})
.into_iter()
.collect())
}
pub fn read_instruction_source(path: &Path) -> io::Result<String> {
fs::read_to_string(path)
}
pub fn import_subagents(source_agents: &Path, target_agents: &Path) -> io::Result<Vec<String>> {
import_subagents_with_rewrite_profile(source_agents, target_agents, Self::REWRITE_PROFILE)
}
pub fn hook_event_names(source_dir: &Path, target_hooks: &Path) -> io::Result<Vec<String>> {
hook_migration_event_names_cur(
source_dir,
&source_dir.join(Self::HOOKS_CONFIG_FILE),
target_hooks,
Self::REWRITE_PROFILE,
)
}
pub fn import_hooks(source_dir: &Path, target_hooks: &Path) -> io::Result<bool> {
import_hooks_cur(
source_dir,
&source_dir.join(Self::HOOKS_CONFIG_FILE),
target_hooks,
Self::REWRITE_PROFILE,
)
}
}
@@ -0,0 +1,91 @@
mod cla;
mod cur;
use crate::invalid_data_error;
use serde_json::Value as JsonValue;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
pub use cla::ClaSource;
pub use cur::CurSource;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstructionSourceGroup {
pub scope: PathBuf,
pub sources: Vec<PathBuf>,
}
fn read_json_file(path: &Path) -> io::Result<Option<JsonValue>> {
if !path.is_file() {
return Ok(None);
}
let raw = fs::read_to_string(path)?;
let value = serde_json::from_str(&raw).map_err(|err| invalid_data_error(err.to_string()))?;
Ok(Some(value))
}
fn build_config(
settings: &JsonValue,
append_source_config: fn(
&mut toml::map::Map<String, TomlValue>,
&serde_json::Map<String, JsonValue>,
),
) -> io::Result<TomlValue> {
let Some(settings) = 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.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),
);
}
append_source_config(&mut root, settings);
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 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())
}
@@ -1,3 +1,5 @@
use crate::ClaSource;
use crate::RewriteProfile;
use codex_core_plugins::CommandDescriptionMode;
use codex_core_plugins::CommandMigrationProfile;
use codex_core_plugins::CommandRewriteProfile;
@@ -5,44 +7,21 @@ use codex_core_plugins::count_missing_commands_with_profile;
use codex_core_plugins::import_commands_with_profile;
use codex_core_plugins::marketplace_add::is_local_marketplace_source;
use codex_core_plugins::missing_command_names_with_profile;
use codex_external_agent_migration::RewriteProfile;
use codex_external_agent_migration::build_mcp_config_from_external;
use codex_external_agent_migration::hook_migration_event_names_cla;
use codex_external_agent_migration::import_hooks_cla;
use codex_external_agent_migration::import_subagents_with_rewrite_profile;
use codex_plugin::PluginId;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
use super::MigrationDetails;
use super::PluginsMigration;
use super::is_non_empty_text_file;
use super::source::DetectedSourcePlugins;
use super::source::InstructionSourceGroup;
use super::source::MarketplaceImportSource;
use super::source::PluginDetectionContext;
use crate::migration_source::MarketplaceImportSource;
use crate::model::MigrationDetails;
use crate::model::PluginsMigration;
pub(super) const CONFIG_DIR: &str = ".claude";
pub(super) const CONFIG_MD: &str = "CLAUDE.md";
pub(super) const KNOWN_MARKETPLACES_PATH: &str = "plugins/known_marketplaces.json";
pub(super) const OFFICIAL_MARKETPLACE_NAME: &str = "claude-plugins-official";
pub(super) const OFFICIAL_MARKETPLACE_SOURCE: &str = "anthropics/claude-plugins-official";
pub(super) const REWRITE_PROFILE: RewriteProfile = RewriteProfile::new(
CONFIG_MD,
&[
"claude code",
"claude-code",
"claude_code",
"claudecode",
"claude",
],
);
pub(super) const REWRITE_PROFILE: RewriteProfile = ClaSource::REWRITE_PROFILE;
const COMMAND_MIGRATION_PROFILE: CommandMigrationProfile = CommandMigrationProfile::new(
CommandRewriteProfile::new(
REWRITE_PROFILE.doc_file_name(),
@@ -52,118 +31,14 @@ const COMMAND_MIGRATION_PROFILE: CommandMigrationProfile = CommandMigrationProfi
CommandDescriptionMode::RequireFrontmatter,
);
pub(super) fn connector_metadata_roots(external_agent_home: &Path) -> Vec<PathBuf> {
let Some(home) = external_agent_home
.parent()
.filter(|path| !path.as_os_str().is_empty())
else {
return Vec::new();
};
#[cfg(target_os = "macos")]
{
vec![home.join("Library/Application Support/Claude")]
}
#[cfg(target_os = "windows")]
{
let default_roaming = home.join("AppData/Roaming");
let default_local = home.join("AppData/Local");
let roaming = std::env::var_os("APPDATA")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| default_roaming.clone());
let local = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| default_local.clone());
let mut roots = vec![
local.join("Packages/Claude_pzs8sxrjxfjjc/LocalCache/Roaming/Claude"),
roaming.join("Claude"),
default_local.join("Packages/Claude_pzs8sxrjxfjjc/LocalCache/Roaming/Claude"),
default_roaming.join("Claude"),
];
roots.sort();
roots.dedup();
roots
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
vec![home.join(".config/Claude")]
}
}
pub(super) fn effective_settings(project_settings: &Path) -> io::Result<Option<JsonValue>> {
let mut effective = super::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 super::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)
}
pub(super) fn append_config(
root: &mut toml::map::Map<String, TomlValue>,
settings: &serde_json::Map<String, JsonValue>,
) {
if settings
.get("sandbox")
.and_then(JsonValue::as_object)
.and_then(|sandbox| sandbox.get("enabled"))
.and_then(JsonValue::as_bool)
== Some(true)
{
root.insert(
"sandbox_mode".to_string(),
TomlValue::String("workspace-write".to_string()),
);
}
}
pub(super) fn detect_plugins(
context: &PluginDetectionContext<'_>,
) -> Option<DetectedSourcePlugins> {
let settings = context.settings?;
let import_sources =
marketplace_import_sources(settings, context.external_agent_home, context.source_root);
let details = extract_plugin_migration_details(
settings,
&import_sources,
context.configured_plugin_ids,
context.configured_marketplace_plugins,
)?;
Some(DetectedSourcePlugins {
description: format!(
"Migrate enabled plugins from {}",
context.source_settings.display()
),
details,
})
}
pub(super) fn can_detect_plugins(settings: Option<&JsonValue>) -> bool {
settings.is_some()
}
pub(super) fn marketplace_import_sources(
settings: &JsonValue,
external_agent_home: &Path,
source_root: &Path,
) -> BTreeMap<String, MarketplaceImportSource> {
let known_marketplaces_path = external_agent_home.join(KNOWN_MARKETPLACES_PATH);
let known_marketplaces = match super::read_external_settings(&known_marketplaces_path) {
let known_marketplaces = match crate::service::read_external_settings(&known_marketplaces_path)
{
Ok(known_marketplaces) => known_marketplaces,
Err(err) => {
tracing::warn!(
@@ -241,43 +116,6 @@ pub(super) fn marketplace_import_sources(
import_sources
}
pub(super) fn build_mcp_config(
source_root: &Path,
external_agent_home: &Path,
settings: Option<&JsonValue>,
) -> io::Result<TomlValue> {
build_mcp_config_from_external(source_root, Some(external_agent_home), settings)
}
pub(super) fn repo_instruction_source_groups(
repo_root: &Path,
) -> io::Result<Vec<InstructionSourceGroup>> {
for candidate in [
repo_root.join(CONFIG_MD),
repo_root.join(CONFIG_DIR).join(CONFIG_MD),
] {
if is_non_empty_text_file(&candidate)? {
return Ok(vec![InstructionSourceGroup {
scope: repo_root.to_path_buf(),
sources: vec![candidate],
}]);
}
}
Ok(Vec::new())
}
pub(super) fn home_instruction_sources(external_agent_home: &Path) -> io::Result<Vec<PathBuf>> {
let path = external_agent_home.join(CONFIG_MD);
Ok(is_non_empty_text_file(&path)?
.then_some(path)
.into_iter()
.collect())
}
pub(super) fn read_instruction_source(path: &Path) -> io::Result<String> {
fs::read_to_string(path)
}
pub(super) fn import_source_commands(
source_commands: &Path,
target_skills: &Path,
@@ -299,43 +137,7 @@ pub(super) fn missing_source_command_names(
missing_command_names_with_profile(source_commands, target_skills, COMMAND_MIGRATION_PROFILE)
}
pub(super) fn import_source_subagents(
source_agents: &Path,
target_agents: &Path,
) -> io::Result<Vec<String>> {
import_subagents_with_rewrite_profile(source_agents, target_agents, REWRITE_PROFILE)
}
pub(super) fn source_hook_event_names(
source_dir: &Path,
target_hooks: &Path,
) -> io::Result<Vec<String>> {
hook_migration_event_names_cla(source_dir, target_hooks, REWRITE_PROFILE)
}
pub(super) fn import_source_hooks(source_dir: &Path, target_hooks: &Path) -> io::Result<bool> {
import_hooks_cla(source_dir, target_hooks, REWRITE_PROFILE)
}
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(
pub(crate) fn extract_plugin_migration_details(
settings: &JsonValue,
import_sources: &BTreeMap<String, MarketplaceImportSource>,
configured_plugin_ids: &HashSet<String>,
@@ -0,0 +1,157 @@
use crate::CurSource;
use crate::RewriteProfile;
use codex_core_plugins::CommandDescriptionMode;
use codex_core_plugins::CommandMigrationProfile;
use codex_core_plugins::CommandRewriteProfile;
use codex_core_plugins::count_missing_commands_with_profile;
use codex_core_plugins::import_commands_with_profile;
use codex_core_plugins::missing_command_names_with_profile;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use crate::migration_source::MarketplaceImportSource;
const PLUGIN_MARKETPLACE_MANIFEST: &str = ".cursor-plugin/marketplace.json";
pub(super) const REWRITE_PROFILE: RewriteProfile = CurSource::REWRITE_PROFILE;
const COMMAND_MIGRATION_PROFILE: CommandMigrationProfile = CommandMigrationProfile::new(
CommandRewriteProfile::new(
REWRITE_PROFILE.doc_file_name(),
REWRITE_PROFILE.term_variants(),
)
.with_case_sensitive_term_variants(REWRITE_PROFILE.case_sensitive_term_variants()),
CommandDescriptionMode::UseSourceNameFallback,
);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct CachedMarketplacePlugins {
pub(super) name: String,
pub(super) source: PathBuf,
pub(super) plugin_names: Vec<String>,
}
pub(super) fn marketplace_import_sources(
external_agent_home: &Path,
) -> io::Result<BTreeMap<String, MarketplaceImportSource>> {
Ok(cached_marketplace_plugins(external_agent_home)?
.into_iter()
.map(|marketplace| {
(
marketplace.name,
MarketplaceImportSource {
source: marketplace.source.display().to_string(),
ref_name: None,
},
)
})
.collect())
}
pub(super) fn import_source_commands(
source_commands: &Path,
target_skills: &Path,
) -> io::Result<Vec<String>> {
import_commands_with_profile(source_commands, target_skills, COMMAND_MIGRATION_PROFILE)
}
pub(super) fn count_missing_source_commands(
source_commands: &Path,
target_skills: &Path,
) -> io::Result<usize> {
count_missing_commands_with_profile(source_commands, target_skills, COMMAND_MIGRATION_PROFILE)
}
pub(super) fn missing_source_command_names(
source_commands: &Path,
target_skills: &Path,
) -> io::Result<Vec<String>> {
missing_command_names_with_profile(source_commands, target_skills, COMMAND_MIGRATION_PROFILE)
}
pub(crate) fn cached_marketplace_plugins(
external_agent_home: &Path,
) -> io::Result<Vec<CachedMarketplacePlugins>> {
let marketplaces_root = external_agent_home.join("plugins/marketplaces");
let cache_root = external_agent_home.join("plugins/cache");
if !marketplaces_root.is_dir() || !cache_root.is_dir() {
return Ok(Vec::new());
}
let mut marketplaces = Vec::new();
for entry in fs::read_dir(marketplaces_root)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let marketplace_root = entry.path();
let manifest_path = marketplace_root.join(PLUGIN_MARKETPLACE_MANIFEST);
if !manifest_path.is_file() {
continue;
}
let manifest = match fs::read_to_string(&manifest_path) {
Ok(manifest) => manifest,
Err(err) => {
tracing::warn!(
path = %manifest_path.display(),
error = %err,
"ignoring unreadable external marketplace manifest"
);
continue;
}
};
let manifest: JsonValue = match serde_json::from_str(&manifest) {
Ok(manifest) => manifest,
Err(err) => {
tracing::warn!(
path = %manifest_path.display(),
error = %err,
"ignoring invalid external marketplace manifest"
);
continue;
}
};
let Some(name) = manifest.get("name").and_then(JsonValue::as_str) else {
continue;
};
let available_plugins = manifest
.get("plugins")
.and_then(JsonValue::as_array)
.into_iter()
.flatten()
.filter_map(|plugin| plugin.get("name").and_then(JsonValue::as_str))
.collect::<BTreeSet<_>>();
let cache_marketplace = cache_root.join(entry.file_name());
if !cache_marketplace.is_dir() {
continue;
}
let mut plugin_names = fs::read_dir(cache_marketplace)?
.filter_map(Result::ok)
.filter_map(|plugin| {
plugin
.file_type()
.ok()
.filter(std::fs::FileType::is_dir)
.and_then(|_| plugin.file_name().into_string().ok())
})
.filter(|plugin_name| available_plugins.contains(plugin_name.as_str()))
.collect::<Vec<_>>();
plugin_names.sort();
if !plugin_names.is_empty() {
marketplaces.push(CachedMarketplacePlugins {
name: name.to_string(),
source: marketplace_root,
plugin_names,
});
}
}
marketplaces.sort_by(|left, right| left.name.cmp(&right.name));
Ok(marketplaces)
}
#[cfg(test)]
#[path = "source_cur_tests.rs"]
mod tests;
@@ -1,26 +1,32 @@
use super::*;
use crate::InstructionSourceGroup;
use crate::detect::plugins::detect_cur_plugins;
use crate::migration_source::PluginDetectionContext;
use crate::model::MigrationDetails;
use crate::model::PluginsMigration;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
use tempfile::TempDir;
use toml::Value as TomlValue;
#[test]
fn effective_settings_merge_sandbox_configuration() {
let root = TempDir::new().expect("tempdir");
let source_dir = root.path().join(CONFIG_DIR);
let source_settings = source_dir.join(HOME_CONFIG_FILE);
let source_dir = root.path().join(CurSource::CONFIG_DIR);
let source_settings = source_dir.join(CurSource::HOME_CONFIG_FILE);
fs::create_dir_all(&source_dir).expect("source directory");
fs::write(&source_settings, r#"{"env":{"FOO":"bar"}}"#).expect("source settings");
fs::write(
source_dir.join(SANDBOX_CONFIG_FILE),
source_dir.join(CurSource::SANDBOX_CONFIG_FILE),
r#"{"type":"read_only"}"#,
)
.expect("sandbox settings");
assert_eq!(
effective_settings(&source_dir, &source_settings).expect("effective settings"),
CurSource::effective_settings(&source_dir, &source_settings).expect("effective settings"),
Some(serde_json::json!({
"env": {"FOO": "bar"},
(SANDBOX_SETTINGS_KEY): {"type": "read_only"}
(CurSource::SANDBOX_SETTINGS_KEY): {"type": "read_only"}
}))
);
}
@@ -30,7 +36,7 @@ fn append_config_maps_workspace_permissions() {
let root = TempDir::new().expect("tempdir");
let writable_root = root.path().join("generated");
let settings = serde_json::json!({
(SANDBOX_SETTINGS_KEY): {
(CurSource::SANDBOX_SETTINGS_KEY): {
"type": "workspace_readwrite",
"additionalReadwritePaths": [writable_root.display().to_string(), "relative/path"],
"disableTmpWrite": true,
@@ -39,7 +45,7 @@ fn append_config_maps_workspace_permissions() {
});
let mut config = toml::map::Map::new();
append_config(&mut config, settings.as_object().expect("settings object"));
CurSource::append_config(&mut config, settings.as_object().expect("settings object"));
let mut workspace_write = toml::map::Map::new();
workspace_write.insert(
@@ -112,10 +118,10 @@ fn detects_uninstalled_plugin_from_configured_marketplace() {
let configured_plugin_ids = HashSet::new();
let configured_marketplace_plugins =
BTreeMap::from([("acme".to_string(), HashSet::from(["sample".to_string()]))]);
let source_settings = root.path().join(HOME_CONFIG_FILE);
let source_settings = root.path().join(CurSource::HOME_CONFIG_FILE);
let source_root = root.path().join("repo");
let detected = detect_plugins(&PluginDetectionContext {
let detected = detect_cur_plugins(&PluginDetectionContext {
external_agent_home: root.path(),
source_settings: &source_settings,
source_root: &source_root,
@@ -142,18 +148,18 @@ fn detects_uninstalled_plugin_from_configured_marketplace() {
#[test]
fn detects_legacy_repo_instruction_file() {
let root = TempDir::new().expect("tempdir");
let source = root.path().join(LEGACY_RULES_FILE);
let source = root.path().join(CurSource::LEGACY_RULES_FILE);
fs::write(&source, "Use the source agent carefully.\n").expect("legacy rules");
assert_eq!(
repo_instruction_source_groups(root.path()).expect("instruction sources"),
CurSource::repo_instruction_source_groups(root.path()).expect("instruction sources"),
vec![InstructionSourceGroup {
scope: root.path().to_path_buf(),
sources: vec![source.clone()],
}]
);
assert_eq!(
read_instruction_source(&source).expect("instruction contents"),
CurSource::read_instruction_source(&source).expect("instruction contents"),
"Use the source agent carefully.\n"
);
}
@@ -0,0 +1,310 @@
use crate::RewriteProfile;
use crate::invalid_data_error;
use serde_yaml::Value as YamlValue;
use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
#[derive(Debug)]
pub(crate) struct ParsedDocument {
pub(crate) frontmatter: BTreeMap<String, FrontmatterValue>,
pub(crate) body: String,
frontmatter_error: Option<String>,
}
#[derive(Debug)]
pub(crate) enum FrontmatterValue {
Scalar(String),
Other,
}
#[derive(Debug)]
pub(crate) struct AgentMetadata {
name: String,
description: String,
permission_mode: Option<String>,
effort: Option<String>,
}
pub fn count_missing_subagents(source_agents: &Path, target_agents: &Path) -> io::Result<usize> {
Ok(missing_subagent_names(source_agents, target_agents)?.len())
}
pub fn missing_subagent_names(
source_agents: &Path,
target_agents: &Path,
) -> io::Result<Vec<String>> {
let mut names = Vec::new();
for source_file in agent_source_files(source_agents)? {
let document = parse_document(&source_file)?;
let Some(metadata) = agent_metadata(&document) else {
continue;
};
let Some(target) = subagent_target_file(&source_file, target_agents) else {
continue;
};
if !target.exists() {
names.push(metadata.name);
}
}
Ok(names)
}
pub fn import_subagents_with_rewrite_profile(
source_agents: &Path,
target_agents: &Path,
rewrite_profile: RewriteProfile,
) -> io::Result<Vec<String>> {
if !source_agents.is_dir() {
return Ok(Vec::new());
}
fs::create_dir_all(target_agents)?;
let mut imported = Vec::new();
for source_file in agent_source_files(source_agents)? {
let Some(target) = subagent_target_file(&source_file, target_agents) else {
continue;
};
if target.exists() {
continue;
}
let document = parse_document(&source_file)?;
let Some(metadata) = agent_metadata(&document) else {
continue;
};
fs::write(
&target,
render_agent_toml(&document.body, &metadata, rewrite_profile)?,
)?;
imported.push(metadata.name);
}
Ok(imported)
}
fn agent_source_files(source_agents: &Path) -> io::Result<Vec<PathBuf>> {
if !source_agents.is_dir() {
return Ok(Vec::new());
}
let mut files = Vec::new();
for entry in fs::read_dir(source_agents)? {
let entry = entry?;
let path = entry.path();
if !entry.file_type()?.is_file()
|| path.extension().and_then(|ext| ext.to_str()) != Some("md")
{
continue;
}
if path.file_stem().and_then(|stem| stem.to_str()) == Some("README") {
continue;
}
files.push(path);
}
files.sort();
Ok(files)
}
pub(crate) fn subagent_target_file(source_file: &Path, target_agents: &Path) -> Option<PathBuf> {
Some(target_agents.join(format!("{}.toml", source_file.file_stem()?.to_str()?)))
}
fn parse_document(source_file: &Path) -> io::Result<ParsedDocument> {
let content = fs::read_to_string(source_file)?;
Ok(parse_document_content(&content))
}
pub(crate) fn parse_document_content(content: &str) -> ParsedDocument {
let Some(rest) = content
.strip_prefix("---\n")
.or_else(|| content.strip_prefix("---\r\n"))
else {
return ParsedDocument {
frontmatter: BTreeMap::new(),
body: content.to_string(),
frontmatter_error: None,
};
};
let Some((end, body_start)) = frontmatter_end(rest) else {
return ParsedDocument {
frontmatter: BTreeMap::new(),
body: content.to_string(),
frontmatter_error: None,
};
};
let raw_frontmatter = &rest[..end];
let body = &rest[body_start..];
let (frontmatter, frontmatter_error) = parse_frontmatter(raw_frontmatter);
ParsedDocument {
frontmatter,
body: body.to_string(),
frontmatter_error,
}
}
fn frontmatter_end(rest: &str) -> Option<(usize, usize)> {
[
"\r\n---\r\n",
"\r\n---\n",
"\n---\r\n",
"\n---\n",
"\r\n---",
"\n---",
]
.into_iter()
.filter_map(|delimiter| rest.find(delimiter).map(|end| (end, end + delimiter.len())))
.min_by_key(|(end, _body_start)| *end)
}
fn parse_frontmatter(
raw_frontmatter: &str,
) -> (BTreeMap<String, FrontmatterValue>, Option<String>) {
let parsed: YamlValue = match serde_yaml::from_str(raw_frontmatter) {
Ok(parsed) => parsed,
Err(err) => return (BTreeMap::new(), Some(err.to_string())),
};
let Some(mapping) = parsed.as_mapping() else {
return (
BTreeMap::new(),
Some("frontmatter is not a YAML mapping".to_string()),
);
};
let mut frontmatter = BTreeMap::new();
for (key, value) in mapping {
let Some(key) = key.as_str().map(str::trim).filter(|key| !key.is_empty()) else {
continue;
};
frontmatter.insert(key.to_string(), frontmatter_value_from_yaml(value));
}
(frontmatter, None)
}
fn frontmatter_value_from_yaml(value: &YamlValue) -> FrontmatterValue {
match value {
YamlValue::String(value) => FrontmatterValue::Scalar(value.trim().to_string()),
YamlValue::Bool(value) => FrontmatterValue::Scalar(value.to_string()),
YamlValue::Number(value) => FrontmatterValue::Scalar(value.to_string()),
YamlValue::Null | YamlValue::Sequence(_) | YamlValue::Mapping(_) | YamlValue::Tagged(_) => {
FrontmatterValue::Other
}
}
}
pub(crate) fn agent_metadata(document: &ParsedDocument) -> Option<AgentMetadata> {
if document.frontmatter_error.is_some() || document.body.trim().is_empty() {
return None;
}
let name = document
.frontmatter
.get("name")
.and_then(FrontmatterValue::as_scalar)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)?;
let description = document
.frontmatter
.get("description")
.and_then(FrontmatterValue::as_scalar)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)?;
Some(AgentMetadata {
name,
description,
permission_mode: frontmatter_string(&document.frontmatter, "permissionMode"),
effort: frontmatter_string(&document.frontmatter, "effort"),
})
}
pub(crate) fn render_agent_toml(
body: &str,
metadata: &AgentMetadata,
rewrite_profile: RewriteProfile,
) -> io::Result<String> {
let mut document = toml::map::Map::new();
document.insert("name".to_string(), TomlValue::String(metadata.name.clone()));
document.insert(
"description".to_string(),
TomlValue::String(rewrite_profile.rewrite(&metadata.description)),
);
if let Some(effort) = metadata.effort.as_ref()
&& let Some(effort) = map_agent_reasoning_effort(effort)
{
document.insert(
"model_reasoning_effort".to_string(),
TomlValue::String(effort),
);
}
if let Some(sandbox_mode) = metadata
.permission_mode
.as_deref()
.and_then(map_agent_permission_mode)
{
document.insert(
"sandbox_mode".to_string(),
TomlValue::String(sandbox_mode.to_string()),
);
}
document.insert(
"developer_instructions".to_string(),
TomlValue::String(render_agent_body(body, rewrite_profile)),
);
let serialized = toml::to_string_pretty(&TomlValue::Table(document))
.map_err(|err| invalid_data_error(format!("failed to serialize agent TOML: {err}")))?;
Ok(format!("{}\n", serialized.trim_end()))
}
fn render_agent_body(body: &str, rewrite_profile: RewriteProfile) -> String {
let body = rewrite_profile.rewrite(body.trim());
if body.is_empty() {
"No subagent instructions were found.".to_string()
} else {
body
}
}
fn frontmatter_string(
frontmatter: &BTreeMap<String, FrontmatterValue>,
key: &str,
) -> Option<String> {
frontmatter
.get(key)
.and_then(FrontmatterValue::as_scalar)
.map(ToOwned::to_owned)
}
fn map_agent_reasoning_effort(effort: &str) -> Option<String> {
let mapped = match effort {
"max" => "xhigh".to_string(),
_ => effort.to_string(),
};
matches!(
mapped.as_str(),
"none" | "minimal" | "low" | "medium" | "high" | "xhigh"
)
.then_some(mapped)
}
fn map_agent_permission_mode(permission_mode: &str) -> Option<&'static str> {
match permission_mode {
"acceptEdits" => Some("workspace-write"),
"readOnly" => Some("read-only"),
_ => None,
}
}
impl FrontmatterValue {
pub(crate) fn as_scalar(&self) -> Option<&str> {
match self {
Self::Scalar(value) => Some(value),
Self::Other => None,
}
}
}
@@ -0,0 +1,93 @@
use crate::RewriteProfile;
use serde_json::Value as JsonValue;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
pub(super) fn display_source_paths(paths: &[PathBuf]) -> String {
paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn read_json_file(path: &Path) -> io::Result<Option<JsonValue>> {
if !path.is_file() {
return Ok(None);
}
let raw = fs::read_to_string(path)?;
serde_json::from_str(&raw)
.map(Some)
.map_err(|err| invalid_data_error(err.to_string()))
}
pub(super) 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())
}
pub(super) fn invalid_data_error(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
pub(super) fn copy_dir_recursive(
source: &Path,
target: &Path,
rewrite_profile: RewriteProfile,
) -> 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, rewrite_profile)?;
continue;
}
if file_type.is_file() {
if is_skill_md(&source_path) {
rewrite_and_copy_text_file(&source_path, &target_path, rewrite_profile)?;
} else {
fs::copy(source_path, target_path)?;
}
}
}
Ok(())
}
pub(super) fn rewrite_external_agent_terms(
content: &str,
rewrite_profile: RewriteProfile,
) -> String {
rewrite_profile.rewrite(content)
}
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,
rewrite_profile: RewriteProfile,
) -> io::Result<()> {
let source_contents = fs::read_to_string(source)?;
let rewritten = rewrite_external_agent_terms(&source_contents, rewrite_profile);
fs::write(target, rewritten)
}