mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
committed by
copyberry
parent
2edad72de3
commit
e7efc5b04b
Generated
+10
@@ -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()
|
||||
},
|
||||
}))
|
||||
}
|
||||
+13
-75
@@ -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)
|
||||
}
|
||||
+2
-1
@@ -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;
|
||||
};
|
||||
+12
-69
@@ -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;
|
||||
+8
-3
@@ -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()
|
||||
|
||||
+4
-32
@@ -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;
|
||||
+55
-77
@@ -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())
|
||||
}
|
||||
+9
-207
@@ -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;
|
||||
+18
-12
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user