Add Cursor support to setup import (#33426)

## What changed

- Detect and import supported Cursor settings, sandbox permissions, MCP servers,
  project instructions, hooks, agents, commands, plugins, and recent chat
  sessions.
- Update the `/import` flow to check Claude Code and Cursor, prompting for a
  source when both have importable data.
- Add `migrationSource` to the app-server detect and import requests so each
  operation uses the selected adapter, while retaining `source` for import
  attribution and backwards compatibility.

## Testing

- Add coverage for Cursor config conversion, hooks, plugins, session discovery
  and conversion, app-server imports, and TUI source selection.

GitOrigin-RevId: a65a1511b6c1c2f9e3e0cfe4f816bb435807282e
This commit is contained in:
charlesgong-openai authored and copyberry committed 2026-07-15 21:03:49 +00:00
1 parent 0f2321f244
commit 1c4af96394
47 files changed
+2962 -247

No files matched your search

+16 -2
View File
@@ -811,8 +811,15 @@
"description": "If true, include detection under the user's home directory.", "description": "If true, include detection under the user's home directory.",
"type": "boolean" "type": "boolean"
}, },
"migrationSource": {
"description": "Optional migration-source selector. Missing or unrecognized values use the default source.",
"type": [
"string",
"null"
]
},
"source": { "source": {
"description": "Optional import-source selector. Missing or unrecognized values use the default source for backwards compatibility.", "description": "Deprecated field retained for compatibility. This field is ignored; use `migrationSource` to select the migration source.",
"type": [ "type": [
"string", "string",
"null" "null"
@@ -829,8 +836,15 @@
}, },
"type": "array" "type": "array"
}, },
"migrationSource": {
"description": "Migration-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source.",
"type": [
"string",
"null"
]
},
"source": { "source": {
"description": "Import-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source for backwards compatibility.", "description": "Optional identifier for the product that initiated the import.",
"type": [ "type": [
"string", "string",
"null" "null"
@@ -9302,8 +9302,15 @@
"description": "If true, include detection under the user's home directory.", "description": "If true, include detection under the user's home directory.",
"type": "boolean" "type": "boolean"
}, },
"migrationSource": {
"description": "Optional migration-source selector. Missing or unrecognized values use the default source.",
"type": [
"string",
"null"
]
},
"source": { "source": {
"description": "Optional import-source selector. Missing or unrecognized values use the default source for backwards compatibility.", "description": "Deprecated field retained for compatibility. This field is ignored; use `migrationSource` to select the migration source.",
"type": [ "type": [
"string", "string",
"null" "null"
@@ -9483,8 +9490,15 @@
}, },
"type": "array" "type": "array"
}, },
"migrationSource": {
"description": "Migration-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source.",
"type": [
"string",
"null"
]
},
"source": { "source": {
"description": "Import-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source for backwards compatibility.", "description": "Optional identifier for the product that initiated the import.",
"type": [ "type": [
"string", "string",
"null" "null"
@@ -5495,8 +5495,15 @@
"description": "If true, include detection under the user's home directory.", "description": "If true, include detection under the user's home directory.",
"type": "boolean" "type": "boolean"
}, },
"migrationSource": {
"description": "Optional migration-source selector. Missing or unrecognized values use the default source.",
"type": [
"string",
"null"
]
},
"source": { "source": {
"description": "Optional import-source selector. Missing or unrecognized values use the default source for backwards compatibility.", "description": "Deprecated field retained for compatibility. This field is ignored; use `migrationSource` to select the migration source.",
"type": [ "type": [
"string", "string",
"null" "null"
@@ -5676,8 +5683,15 @@
}, },
"type": "array" "type": "array"
}, },
"migrationSource": {
"description": "Migration-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source.",
"type": [
"string",
"null"
]
},
"source": { "source": {
"description": "Import-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source for backwards compatibility.", "description": "Optional identifier for the product that initiated the import.",
"type": [ "type": [
"string", "string",
"null" "null"
@@ -15,8 +15,15 @@
"description": "If true, include detection under the user's home directory.", "description": "If true, include detection under the user's home directory.",
"type": "boolean" "type": "boolean"
}, },
"migrationSource": {
"description": "Optional migration-source selector. Missing or unrecognized values use the default source.",
"type": [
"string",
"null"
]
},
"source": { "source": {
"description": "Optional import-source selector. Missing or unrecognized values use the default source for backwards compatibility.", "description": "Deprecated field retained for compatibility. This field is ignored; use `migrationSource` to select the migration source.",
"type": [ "type": [
"string", "string",
"null" "null"
@@ -203,8 +203,15 @@
}, },
"type": "array" "type": "array"
}, },
"migrationSource": {
"description": "Migration-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source.",
"type": [
"string",
"null"
]
},
"source": { "source": {
"description": "Import-source selector used to produce the migration items. Pass the same value to detection and import; missing or unrecognized values use the default source for backwards compatibility.", "description": "Optional identifier for the product that initiated the import.",
"type": [ "type": [
"string", "string",
"null" "null"
@@ -12,7 +12,11 @@ includeHome?: boolean,
*/ */
cwds?: Array<string> | null, cwds?: Array<string> | null,
/** /**
* Optional import-source selector. Missing or unrecognized values use the default source for * Deprecated field retained for compatibility. This field is ignored; use `migrationSource`
* backwards compatibility. * to select the migration source.
*/ */
source?: string | null, }; source?: string | null,
/**
* Optional migration-source selector. Missing or unrecognized values use the default source.
*/
migrationSource?: string | null, };
@@ -5,8 +5,11 @@ import type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigr
export type ExternalAgentConfigImportParams = { migrationItems: Array<ExternalAgentConfigMigrationItem>, export type ExternalAgentConfigImportParams = { migrationItems: Array<ExternalAgentConfigMigrationItem>,
/** /**
* Import-source selector used to produce the migration items. Pass the same value to detection * Optional identifier for the product that initiated the import.
* and import; missing or unrecognized values use the default source for backwards
* compatibility.
*/ */
source?: string | null, }; source?: string | null,
/**
* Migration-source selector used to produce the migration items. Pass the same value to
* detection and import; missing or unrecognized values use the default source.
*/
migrationSource?: string | null, };
@@ -684,10 +684,13 @@ pub struct ExternalAgentConfigDetectParams {
/// Zero or more working directories to include for repo-scoped detection. /// Zero or more working directories to include for repo-scoped detection.
#[ts(optional = nullable)] #[ts(optional = nullable)]
pub cwds: Option<Vec<PathBuf>>, pub cwds: Option<Vec<PathBuf>>,
/// Optional import-source selector. Missing or unrecognized values use the default source for /// Deprecated field retained for compatibility. This field is ignored; use `migrationSource`
/// backwards compatibility. /// to select the migration source.
#[ts(optional = nullable)] #[ts(optional = nullable)]
pub source: Option<String>, pub source: Option<String>,
/// Optional migration-source selector. Missing or unrecognized values use the default source.
#[ts(optional = nullable)]
pub migration_source: Option<String>,
} }
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -695,11 +698,13 @@ pub struct ExternalAgentConfigDetectParams {
#[ts(export_to = "v2/")] #[ts(export_to = "v2/")]
pub struct ExternalAgentConfigImportParams { pub struct ExternalAgentConfigImportParams {
pub migration_items: Vec<ExternalAgentConfigMigrationItem>, pub migration_items: Vec<ExternalAgentConfigMigrationItem>,
/// Import-source selector used to produce the migration items. Pass the same value to detection /// Optional identifier for the product that initiated the import.
/// and import; missing or unrecognized values use the default source for backwards
/// compatibility.
#[ts(optional = nullable)] #[ts(optional = nullable)]
pub source: Option<String>, pub source: Option<String>,
/// Migration-source selector used to produce the migration items. Pass the same value to
/// detection and import; missing or unrecognized values use the default source.
#[ts(optional = nullable)]
pub migration_source: Option<String>,
} }
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -435,6 +435,7 @@ fn external_agent_config_import_params_accept_legacy_plugin_details() {
}), }),
}], }],
source: None, source: None,
migration_source: None,
} }
); );
} }
+2 -2
View File
@@ -241,8 +241,8 @@ Example with notification opt-out:
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
- `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`. - `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`.
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome`, optional `cwds`, and an optional `source` identifier reserved for adapter selection; omitted, `null`, or unrecognized values retain the default behavior. Each detected item includes `cwd` (`null` for home), and multi-item migrations may additionally include structured `details` with plugin ids, skill names, session metadata, or other artifact names. - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome`, optional `cwds`, and an optional `migrationSource` selector. Omitted, `null`, or unrecognized migration-source values retain the default behavior. The deprecated optional `source` field remains accepted for compatibility but does not select the migration source. Each detected item includes `cwd` (`null` for home), and multi-item migrations may additionally include structured `details` with plugin ids, skill names, session metadata, or other artifact names.
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any `details` returned by detect. Callers may pass `source` to identify the product that initiated the import; omitted or `null` means unspecified. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately. - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any `details` returned by detect. Pass the same optional `migrationSource` used for detection so the server reads from the matching source; omitted, `null`, or unrecognized values retain the default behavior. The optional `source` continues to identify the product that initiated the import. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately.
- `externalAgentConfig/import/readHistories` — read completed import histories and connector candidates detected from successfully imported session histories. Connector candidates include a normalized display `name`, the number of imported sessions that used the connector, and the source metadata field used for detection. - `externalAgentConfig/import/readHistories` — read completed import histories and connector candidates detected from successfully imported session histories. Connector candidates include a normalized display `name`, the number of imported sessions that used the connector, and the source metadata field used for detection.
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. - `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits.
@@ -50,7 +50,6 @@ use super::service::ExternalAgentConfigMigrationItem as CoreMigrationItem;
use super::service::ExternalAgentConfigMigrationItemType as CoreMigrationItemType; use super::service::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
use super::service::ExternalAgentConfigService; use super::service::ExternalAgentConfigService;
use super::service::NamedMigration as CoreNamedMigration; use super::service::NamedMigration as CoreNamedMigration;
use super::service::PendingPluginImport;
use super::service::PluginImportOutcome; use super::service::PluginImportOutcome;
use super::service::record_import_error; use super::service::record_import_error;
use super::session_importer::ExternalAgentSessionImporter; use super::session_importer::ExternalAgentSessionImporter;
@@ -117,8 +116,10 @@ impl ExternalAgentConfigRequestProcessor {
&self, &self,
params: ExternalAgentConfigDetectParams, params: ExternalAgentConfigDetectParams,
) -> Result<ExternalAgentConfigDetectResponse, JSONRPCErrorError> { ) -> Result<ExternalAgentConfigDetectResponse, JSONRPCErrorError> {
let items = self let migration_service = self
.migration_service .migration_service
.with_migration_source(params.migration_source.as_deref());
let items = migration_service
.detect(ExternalAgentConfigDetectOptions { .detect(ExternalAgentConfigDetectOptions {
include_home: params.include_home, include_home: params.include_home,
cwds: params.cwds, cwds: params.cwds,
@@ -219,6 +220,9 @@ impl ExternalAgentConfigRequestProcessor {
) -> Result<(), JSONRPCErrorError> { ) -> Result<(), JSONRPCErrorError> {
let import_id = Uuid::new_v4().to_string(); let import_id = Uuid::new_v4().to_string();
let analytics_source = params.source.clone().unwrap_or_default(); let analytics_source = params.source.clone().unwrap_or_default();
let migration_service = self
.migration_service
.with_migration_source(params.migration_source.as_deref());
let needs_runtime_refresh = migration_items_need_runtime_refresh(&params.migration_items); let needs_runtime_refresh = migration_items_need_runtime_refresh(&params.migration_items);
let has_migration_items = !params.migration_items.is_empty(); let has_migration_items = !params.migration_items.is_empty();
let has_plugin_imports = params.migration_items.iter().any(|item| { let has_plugin_imports = params.migration_items.iter().any(|item| {
@@ -228,8 +232,10 @@ impl ExternalAgentConfigRequestProcessor {
) )
}); });
let (pending_session_imports, session_validation_result) = let (pending_session_imports, session_validation_result) =
self.validate_pending_session_imports(&params); self.validate_pending_session_imports(&params, &migration_service);
let import_outcome = self.import_external_agent_config(params).await; let import_outcome = self
.import_external_agent_config(params, &migration_service)
.await;
if needs_runtime_refresh { if needs_runtime_refresh {
self.config_processor.handle_config_mutation().await; self.config_processor.handle_config_mutation().await;
} }
@@ -272,11 +278,12 @@ impl ExternalAgentConfigRequestProcessor {
} }
let session_importer = self.session_importer.clone(); let session_importer = self.session_importer.clone();
let plugin_processor = self.clone();
let outgoing = Arc::clone(&self.outgoing); let outgoing = Arc::clone(&self.outgoing);
let state_db = self.state_db.clone(); let state_db = self.state_db.clone();
let analytics_events_client = self.analytics_events_client.clone(); let analytics_events_client = self.analytics_events_client.clone();
let thread_manager = Arc::clone(&self.thread_manager); let thread_manager = Arc::clone(&self.thread_manager);
let session_metadata_mode = migration_service.session_metadata_mode();
let plugin_migration_service = migration_service;
let session_import_result = (!pending_session_imports.is_empty()).then(|| { let session_import_result = (!pending_session_imports.is_empty()).then(|| {
CoreImportItemResult::new( CoreImportItemResult::new(
CoreMigrationItemType::Sessions, CoreMigrationItemType::Sessions,
@@ -291,7 +298,11 @@ impl ExternalAgentConfigRequestProcessor {
let session_imports = async move { let session_imports = async move {
let session_import_result = session_import_result?; let session_import_result = session_import_result?;
let item_result = session_importer let item_result = session_importer
.import_sessions(pending_session_imports, session_import_result) .import_sessions(
pending_session_imports,
session_import_result,
session_metadata_mode,
)
.await; .await;
send_import_progress(&session_progress_outgoing, &session_import_id, &item_result) send_import_progress(&session_progress_outgoing, &session_import_id, &item_result)
.await; .await;
@@ -307,8 +318,11 @@ impl ExternalAgentConfigRequestProcessor {
pending_plugin_import.description.clone(), pending_plugin_import.description.clone(),
pending_plugin_import.cwd.clone(), pending_plugin_import.cwd.clone(),
); );
match plugin_processor match plugin_migration_service
.complete_pending_plugin_import(pending_plugin_import) .import_plugins(
pending_plugin_import.cwd.as_deref(),
Some(pending_plugin_import.details),
)
.await .await
{ {
Ok(plugin_outcome) => { Ok(plugin_outcome) => {
@@ -318,7 +332,7 @@ impl ExternalAgentConfigRequestProcessor {
record_import_error( record_import_error(
&mut item_result, &mut item_result,
"plugin_import", "plugin_import",
error.message.clone(), error.to_string(),
/*source*/ None, /*source*/ None,
); );
} }
@@ -393,6 +407,7 @@ impl ExternalAgentConfigRequestProcessor {
fn validate_pending_session_imports( fn validate_pending_session_imports(
&self, &self,
params: &ExternalAgentConfigImportParams, params: &ExternalAgentConfigImportParams,
migration_service: &ExternalAgentConfigService,
) -> (Vec<CoreSessionMigration>, Option<CoreImportItemResult>) { ) -> (Vec<CoreSessionMigration>, Option<CoreImportItemResult>) {
let sessions = params let sessions = params
.migration_items .migration_items
@@ -422,33 +437,31 @@ impl ExternalAgentConfigRequestProcessor {
let mut selected_session_paths = HashSet::new(); let mut selected_session_paths = HashSet::new();
let mut selected_sessions = Vec::new(); let mut selected_sessions = Vec::new();
for session in sessions { for session in sessions {
let canonical_path = match self let canonical_path =
.migration_service match migration_service.external_agent_session_source_path(&session.path) {
.external_agent_session_source_path(&session.path) Ok(Some(canonical_path)) => canonical_path,
{ Ok(None) => {
Ok(Some(canonical_path)) => canonical_path, record_import_error(
Ok(None) => { &mut item_result,
record_import_error( "session_missing",
&mut item_result, format!(
"session_missing", "external agent session was not detected for import: {}",
format!( session.path.display()
"external agent session was not detected for import: {}", ),
session.path.display() Some(session.path.display().to_string()),
), );
Some(session.path.display().to_string()), continue;
); }
continue; Err(err) => {
} record_import_error(
Err(err) => { &mut item_result,
record_import_error( "session_source_path",
&mut item_result, err.to_string(),
"session_source_path", Some(session.path.display().to_string()),
err.to_string(), );
Some(session.path.display().to_string()), continue;
); }
continue; };
}
};
if selected_session_paths.insert(canonical_path) { if selected_session_paths.insert(canonical_path) {
selected_sessions.push(session); selected_sessions.push(session);
} }
@@ -459,8 +472,9 @@ impl ExternalAgentConfigRequestProcessor {
async fn import_external_agent_config( async fn import_external_agent_config(
&self, &self,
params: ExternalAgentConfigImportParams, params: ExternalAgentConfigImportParams,
migration_service: &ExternalAgentConfigService,
) -> CoreImportOutcome { ) -> CoreImportOutcome {
self.migration_service migration_service
.import( .import(
params params
.migration_items .migration_items
@@ -558,19 +572,6 @@ impl ExternalAgentConfigRequestProcessor {
) )
.await .await
} }
async fn complete_pending_plugin_import(
&self,
pending_plugin_import: PendingPluginImport,
) -> Result<PluginImportOutcome, JSONRPCErrorError> {
self.migration_service
.import_plugins(
pending_plugin_import.cwd.as_deref(),
Some(pending_plugin_import.details),
)
.await
.map_err(|err| internal_error(err.to_string()))
}
} }
async fn send_import_progress( async fn send_import_progress(
@@ -1,5 +1,6 @@
mod source; mod source;
mod source_cla; mod source_cla;
mod source_cur;
mod utils; mod utils;
use codex_analytics::AnalyticsEventsClient; use codex_analytics::AnalyticsEventsClient;
@@ -19,6 +20,7 @@ use codex_core_plugins::marketplace_add::is_local_marketplace_source;
use codex_external_agent_migration::count_missing_subagents; use codex_external_agent_migration::count_missing_subagents;
use codex_external_agent_migration::missing_subagent_names; use codex_external_agent_migration::missing_subagent_names;
use codex_external_agent_migration::sessions::ExternalAgentSessionMigration; use codex_external_agent_migration::sessions::ExternalAgentSessionMigration;
use codex_external_agent_migration::sessions::SessionMetadataMode;
use codex_protocol::protocol::Product; use codex_protocol::protocol::Product;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -208,6 +210,23 @@ impl ExternalAgentConfigService {
} }
} }
pub(crate) 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,
}
}
pub(crate) fn session_metadata_mode(&self) -> SessionMetadataMode {
self.source.session_metadata_mode()
}
#[cfg(test)] #[cfg(test)]
fn new_for_test(codex_home: PathBuf, external_agent_home: PathBuf) -> Self { fn new_for_test(codex_home: PathBuf, external_agent_home: PathBuf) -> Self {
let source = ExternalAgentSource::default(); let source = ExternalAgentSource::default();
@@ -520,7 +539,10 @@ impl ExternalAgentConfigService {
} }
} }
let mcp_source_path = self.source.mcp_source_path(self.source_root(repo_root)); let mcp_source_path = self.source.mcp_source_path(
self.source_root(repo_root),
self.source_config_dir(repo_root),
);
let migrated_mcp = self.build_mcp_config(repo_root, settings.clone())?; let migrated_mcp = self.build_mcp_config(repo_root, settings.clone())?;
let mut mcp_server_names = migrated_mcp_server_names(&migrated_mcp); let mut mcp_server_names = migrated_mcp_server_names(&migrated_mcp);
if !is_empty_toml_table(&migrated_mcp) { if !is_empty_toml_table(&migrated_mcp) {
@@ -747,6 +769,7 @@ impl ExternalAgentConfigService {
external_agent_home: self.external_agent_home.as_path(), external_agent_home: self.external_agent_home.as_path(),
source_settings: source_settings.as_path(), source_settings: source_settings.as_path(),
source_root, source_root,
repo_root,
settings: settings.as_ref(), settings: settings.as_ref(),
configured_plugin_ids: &configured_plugin_ids, configured_plugin_ids: &configured_plugin_ids,
configured_marketplace_plugins: &configured_marketplace_plugins, configured_marketplace_plugins: &configured_marketplace_plugins,
@@ -823,7 +846,10 @@ impl ExternalAgentConfigService {
fn effective_source_settings(&self, repo_root: Option<&Path>) -> io::Result<Option<JsonValue>> { fn effective_source_settings(&self, repo_root: Option<&Path>) -> io::Result<Option<JsonValue>> {
let source_settings = self.source_settings(repo_root); let source_settings = self.source_settings(repo_root);
self.source.effective_settings(&source_settings) self.source.effective_settings(
self.source_config_dir(repo_root).as_path(),
&source_settings,
)
} }
fn build_mcp_config( fn build_mcp_config(
@@ -838,6 +864,7 @@ impl ExternalAgentConfigService {
}; };
self.source.build_mcp_config( self.source.build_mcp_config(
self.source_root(repo_root).as_path(), self.source_root(repo_root).as_path(),
self.source_config_dir(repo_root).as_path(),
self.external_agent_home.as_path(), self.external_agent_home.as_path(),
settings.as_ref(), settings.as_ref(),
) )
@@ -969,6 +996,19 @@ impl ExternalAgentConfigService {
if let Some(analytics_events_client) = self.analytics_events_client.clone() { if let Some(analytics_events_client) = self.analytics_events_client.clone() {
plugins_manager.set_analytics_events_client(analytics_events_client); 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)?; let import_sources = self.marketplace_import_sources(cwd)?;
for plugin_group in plugins { for plugin_group in plugins {
let marketplace_name = plugin_group.marketplace_name.clone(); let marketplace_name = plugin_group.marketplace_name.clone();
@@ -977,64 +1017,71 @@ impl ExternalAgentConfigService {
.iter() .iter()
.map(|plugin_name| format!("{plugin_name}@{marketplace_name}")) .map(|plugin_name| format!("{plugin_name}@{marketplace_name}"))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let import_source = import_sources.get(&marketplace_name).cloned(); let marketplace_path = if let Some(marketplace_path) =
let Some(import_source) = import_source else { configured_marketplace_paths.get(&marketplace_name)
let message = format!( {
"external agent plugin marketplace source was not found: {marketplace_name}" outcome
); .succeeded_marketplaces
record_plugin_import_errors( .push(marketplace_name.clone());
&mut outcome, marketplace_path.clone()
cwd, } else {
&plugin_ids, let Some(import_source) = import_sources.get(&marketplace_name).cloned() else {
"plugin_import", let message = format!(
message, "external agent plugin marketplace source was not found: {marketplace_name}"
); );
outcome.failed_marketplaces.push(marketplace_name);
outcome.failed_plugin_ids.extend(plugin_ids);
continue;
};
let request = MarketplaceAddRequest {
source: import_source.source,
ref_name: import_source.ref_name,
sparse_paths: Vec::new(),
};
let add_marketplace_outcome =
add_marketplace(self.codex_home.clone(), requirements.clone(), request).await;
let marketplace_path = match add_marketplace_outcome {
Ok(add_marketplace_outcome) => {
let Some(marketplace_path) = find_marketplace_manifest_path(
add_marketplace_outcome.installed_root.as_path(),
) else {
let message = format!(
"plugin marketplace manifest was not found after install: {marketplace_name}"
);
record_plugin_import_errors(
&mut outcome,
cwd,
&plugin_ids,
"plugin_import",
message,
);
outcome.failed_marketplaces.push(marketplace_name);
outcome.failed_plugin_ids.extend(plugin_ids);
continue;
};
outcome
.succeeded_marketplaces
.push(marketplace_name.clone());
marketplace_path
}
Err(err) => {
record_plugin_import_errors( record_plugin_import_errors(
&mut outcome, &mut outcome,
cwd, cwd,
&plugin_ids, &plugin_ids,
"plugin_import", "plugin_import",
err.to_string(), message,
); );
outcome.failed_marketplaces.push(marketplace_name); outcome.failed_marketplaces.push(marketplace_name);
outcome.failed_plugin_ids.extend(plugin_ids); outcome.failed_plugin_ids.extend(plugin_ids);
continue; 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() let install_config = match ConfigBuilder::default()
@@ -1,6 +1,8 @@
use codex_external_agent_migration::RewriteProfile; use codex_external_agent_migration::RewriteProfile;
use codex_external_agent_migration::sessions::ExternalAgentSessionMigration; 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_cla_sessions;
use codex_external_agent_migration::sessions::detect_recent_cur_sessions;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::collections::HashSet; use std::collections::HashSet;
@@ -11,6 +13,7 @@ use toml::Value as TomlValue;
use super::MigrationDetails; use super::MigrationDetails;
use super::source_cla; use super::source_cla;
use super::source_cur;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct InstructionSourceGroup { pub(super) struct InstructionSourceGroup {
@@ -33,6 +36,7 @@ pub(super) struct PluginDetectionContext<'a> {
pub(super) external_agent_home: &'a Path, pub(super) external_agent_home: &'a Path,
pub(super) source_settings: &'a Path, pub(super) source_settings: &'a Path,
pub(super) source_root: &'a Path, pub(super) source_root: &'a Path,
pub(super) repo_root: Option<&'a Path>,
pub(super) settings: Option<&'a JsonValue>, pub(super) settings: Option<&'a JsonValue>,
pub(super) configured_plugin_ids: &'a HashSet<String>, pub(super) configured_plugin_ids: &'a HashSet<String>,
pub(super) configured_marketplace_plugins: &'a BTreeMap<String, HashSet<String>>, pub(super) configured_marketplace_plugins: &'a BTreeMap<String, HashSet<String>>,
@@ -42,6 +46,7 @@ pub(super) struct PluginDetectionContext<'a> {
pub(super) enum ExternalAgentSource { pub(super) enum ExternalAgentSource {
#[default] #[default]
Cla, Cla,
Cur,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -52,9 +57,20 @@ pub(super) enum SourceFeature {
} }
impl ExternalAgentSource { 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))
{
Self::Cur
} else {
Self::Cla
}
}
pub(super) fn config_dir(self) -> &'static str { pub(super) fn config_dir(self) -> &'static str {
match self { match self {
Self::Cla => source_cla::CONFIG_DIR, Self::Cla => source_cla::CONFIG_DIR,
Self::Cur => source_cur::CONFIG_DIR,
} }
} }
@@ -64,21 +80,29 @@ impl ExternalAgentSource {
Self::Cla, Self::Cla,
SourceFeature::Config | SourceFeature::Plugins | SourceFeature::Sessions, SourceFeature::Config | SourceFeature::Plugins | SourceFeature::Sessions,
) => true, ) => true,
(
Self::Cur,
SourceFeature::Config | SourceFeature::Plugins | SourceFeature::Sessions,
) => true,
} }
} }
pub(super) fn settings_file_name(self, project_scope: bool) -> &'static str { pub(super) fn settings_file_name(self, project_scope: bool) -> &'static str {
match (self, project_scope) { match (self, project_scope) {
(Self::Cla, _) => "settings.json", (Self::Cla, _) => "settings.json",
(Self::Cur, false) => source_cur::HOME_CONFIG_FILE,
(Self::Cur, true) => source_cur::PROJECT_CONFIG_FILE,
} }
} }
pub(super) fn effective_settings( pub(super) fn effective_settings(
self, self,
source_config_dir: &Path,
source_settings: &Path, source_settings: &Path,
) -> io::Result<Option<JsonValue>> { ) -> io::Result<Option<JsonValue>> {
match self { match self {
Self::Cla => source_cla::effective_settings(source_settings), Self::Cla => source_cla::effective_settings(source_settings),
Self::Cur => source_cur::effective_settings(source_config_dir, source_settings),
} }
} }
@@ -88,12 +112,15 @@ impl ExternalAgentSource {
) -> io::Result<Option<DetectedSourcePlugins>> { ) -> io::Result<Option<DetectedSourcePlugins>> {
match self { match self {
Self::Cla => Ok(source_cla::detect_plugins(&context)), Self::Cla => Ok(source_cla::detect_plugins(&context)),
Self::Cur if context.repo_root.is_none() => source_cur::detect_plugins(&context),
Self::Cur => Ok(None),
} }
} }
pub(super) fn can_detect_plugins(self, settings: Option<&JsonValue>) -> bool { pub(super) fn can_detect_plugins(self, settings: Option<&JsonValue>) -> bool {
match self { match self {
Self::Cla => source_cla::can_detect_plugins(settings), Self::Cla => source_cla::can_detect_plugins(settings),
Self::Cur => true,
} }
} }
@@ -104,12 +131,21 @@ impl ExternalAgentSource {
) -> io::Result<Vec<ExternalAgentSessionMigration>> { ) -> io::Result<Vec<ExternalAgentSessionMigration>> {
match self { match self {
Self::Cla => detect_recent_cla_sessions(external_agent_home, codex_home), Self::Cla => detect_recent_cla_sessions(external_agent_home, codex_home),
Self::Cur => detect_recent_cur_sessions(external_agent_home, codex_home),
}
}
pub(super) fn session_metadata_mode(self) -> SessionMetadataMode {
match self {
Self::Cla => SessionMetadataMode::Embedded,
Self::Cur => SessionMetadataMode::MigrationFallback,
} }
} }
pub(super) fn connector_metadata_roots(self, external_agent_home: &Path) -> Vec<PathBuf> { pub(super) fn connector_metadata_roots(self, external_agent_home: &Path) -> Vec<PathBuf> {
match self { match self {
Self::Cla => source_cla::connector_metadata_roots(external_agent_home), Self::Cla => source_cla::connector_metadata_roots(external_agent_home),
Self::Cur => Vec::new(),
} }
} }
@@ -130,6 +166,7 @@ impl ExternalAgentSource {
) )
}) })
.unwrap_or_default()), .unwrap_or_default()),
Self::Cur => source_cur::marketplace_import_sources(external_agent_home),
} }
} }
@@ -140,23 +177,31 @@ impl ExternalAgentSource {
) { ) {
match self { match self {
Self::Cla => source_cla::append_config(root, settings), Self::Cla => source_cla::append_config(root, settings),
Self::Cur => source_cur::append_config(root, settings),
} }
} }
pub(super) fn build_mcp_config( pub(super) fn build_mcp_config(
self, self,
source_root: &Path, source_root: &Path,
source_config_dir: &Path,
external_agent_home: &Path, external_agent_home: &Path,
settings: Option<&JsonValue>, settings: Option<&JsonValue>,
) -> io::Result<TomlValue> { ) -> io::Result<TomlValue> {
match self { match self {
Self::Cla => source_cla::build_mcp_config(source_root, external_agent_home, settings), Self::Cla => source_cla::build_mcp_config(source_root, external_agent_home, settings),
Self::Cur => source_cur::build_mcp_config(source_config_dir),
} }
} }
pub(super) fn mcp_source_path(self, source_root: PathBuf) -> PathBuf { pub(super) fn mcp_source_path(
self,
source_root: PathBuf,
source_config_dir: PathBuf,
) -> PathBuf {
match self { match self {
Self::Cla => source_root, Self::Cla => source_root,
Self::Cur => source_config_dir.join("mcp.json"),
} }
} }
@@ -166,6 +211,7 @@ impl ExternalAgentSource {
) -> io::Result<Vec<InstructionSourceGroup>> { ) -> io::Result<Vec<InstructionSourceGroup>> {
match self { match self {
Self::Cla => source_cla::repo_instruction_source_groups(repo_root), Self::Cla => source_cla::repo_instruction_source_groups(repo_root),
Self::Cur => source_cur::repo_instruction_source_groups(repo_root),
} }
} }
@@ -175,12 +221,14 @@ impl ExternalAgentSource {
) -> io::Result<Vec<PathBuf>> { ) -> io::Result<Vec<PathBuf>> {
match self { match self {
Self::Cla => source_cla::home_instruction_sources(external_agent_home), Self::Cla => source_cla::home_instruction_sources(external_agent_home),
Self::Cur => Ok(Vec::new()),
} }
} }
pub(super) fn read_instruction_source(self, path: &Path) -> io::Result<String> { pub(super) fn read_instruction_source(self, path: &Path) -> io::Result<String> {
match self { match self {
Self::Cla => source_cla::read_instruction_source(path), Self::Cla => source_cla::read_instruction_source(path),
Self::Cur => source_cur::read_instruction_source(path),
} }
} }
@@ -191,6 +239,7 @@ impl ExternalAgentSource {
) -> io::Result<Vec<String>> { ) -> io::Result<Vec<String>> {
match self { match self {
Self::Cla => source_cla::import_source_commands(source_commands, target_skills), Self::Cla => source_cla::import_source_commands(source_commands, target_skills),
Self::Cur => source_cur::import_source_commands(source_commands, target_skills),
} }
} }
@@ -201,6 +250,7 @@ impl ExternalAgentSource {
) -> io::Result<usize> { ) -> io::Result<usize> {
match self { match self {
Self::Cla => source_cla::count_missing_source_commands(source_commands, target_skills), Self::Cla => source_cla::count_missing_source_commands(source_commands, target_skills),
Self::Cur => source_cur::count_missing_source_commands(source_commands, target_skills),
} }
} }
@@ -211,6 +261,7 @@ impl ExternalAgentSource {
) -> io::Result<Vec<String>> { ) -> io::Result<Vec<String>> {
match self { match self {
Self::Cla => source_cla::missing_source_command_names(source_commands, target_skills), Self::Cla => source_cla::missing_source_command_names(source_commands, target_skills),
Self::Cur => source_cur::missing_source_command_names(source_commands, target_skills),
} }
} }
@@ -221,6 +272,7 @@ impl ExternalAgentSource {
) -> io::Result<Vec<String>> { ) -> io::Result<Vec<String>> {
match self { match self {
Self::Cla => source_cla::import_source_subagents(source_agents, target_agents), Self::Cla => source_cla::import_source_subagents(source_agents, target_agents),
Self::Cur => source_cur::import_source_subagents(source_agents, target_agents),
} }
} }
@@ -231,18 +283,21 @@ impl ExternalAgentSource {
) -> io::Result<Vec<String>> { ) -> io::Result<Vec<String>> {
match self { match self {
Self::Cla => source_cla::source_hook_event_names(source_dir, target_hooks), Self::Cla => source_cla::source_hook_event_names(source_dir, target_hooks),
Self::Cur => source_cur::source_hook_event_names(source_dir, target_hooks),
} }
} }
pub(super) fn import_hooks(self, source_dir: &Path, target_hooks: &Path) -> io::Result<bool> { pub(super) fn import_hooks(self, source_dir: &Path, target_hooks: &Path) -> io::Result<bool> {
match self { match self {
Self::Cla => source_cla::import_source_hooks(source_dir, target_hooks), Self::Cla => source_cla::import_source_hooks(source_dir, target_hooks),
Self::Cur => source_cur::import_source_hooks(source_dir, target_hooks),
} }
} }
pub(super) fn rewrite_profile(self) -> RewriteProfile { pub(super) fn rewrite_profile(self) -> RewriteProfile {
match self { match self {
Self::Cla => source_cla::REWRITE_PROFILE, Self::Cla => source_cla::REWRITE_PROFILE,
Self::Cur => source_cur::REWRITE_PROFILE,
} }
} }
} }
@@ -0,0 +1,347 @@
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;
@@ -0,0 +1,159 @@
use super::*;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
use tempfile::TempDir;
#[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);
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),
r#"{"type":"read_only"}"#,
)
.expect("sandbox settings");
assert_eq!(
effective_settings(&source_dir, &source_settings).expect("effective settings"),
Some(serde_json::json!({
"env": {"FOO": "bar"},
(SANDBOX_SETTINGS_KEY): {"type": "read_only"}
}))
);
}
#[test]
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): {
"type": "workspace_readwrite",
"additionalReadwritePaths": [writable_root.display().to_string(), "relative/path"],
"disableTmpWrite": true,
"networkPolicy": {"default": "allow"}
}
});
let mut config = toml::map::Map::new();
append_config(&mut config, settings.as_object().expect("settings object"));
let mut workspace_write = toml::map::Map::new();
workspace_write.insert(
"writable_roots".to_string(),
TomlValue::Array(vec![TomlValue::String(
writable_root.to_string_lossy().into_owned(),
)]),
);
workspace_write.insert("exclude_slash_tmp".to_string(), TomlValue::Boolean(true));
workspace_write.insert(
"exclude_tmpdir_env_var".to_string(),
TomlValue::Boolean(true),
);
workspace_write.insert("network_access".to_string(), TomlValue::Boolean(true));
let mut expected = toml::map::Map::new();
expected.insert(
"sandbox_mode".to_string(),
TomlValue::String("workspace-write".to_string()),
);
expected.insert(
"sandbox_workspace_write".to_string(),
TomlValue::Table(workspace_write),
);
assert_eq!(TomlValue::Table(config), TomlValue::Table(expected));
}
#[test]
fn cached_marketplace_plugins_require_manifest_and_cache_entries() {
let root = TempDir::new().expect("tempdir");
let marketplace_root = root.path().join("plugins/marketplaces/acme");
let cache_root = root.path().join("plugins/cache/acme");
let manifest_path = marketplace_root.join(PLUGIN_MARKETPLACE_MANIFEST);
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
.expect("manifest directory");
fs::create_dir_all(cache_root.join("sample")).expect("cached plugin");
fs::create_dir_all(cache_root.join("not-listed")).expect("unlisted cached plugin");
fs::write(
&manifest_path,
r#"{
"name": "acme",
"plugins": [{"name": "sample"}, {"name": "not-cached"}]
}"#,
)
.expect("marketplace manifest");
assert_eq!(
cached_marketplace_plugins(root.path()).expect("cached marketplace plugins"),
vec![CachedMarketplacePlugins {
name: "acme".to_string(),
source: marketplace_root,
plugin_names: vec!["sample".to_string()],
}]
);
}
#[test]
fn detects_uninstalled_plugin_from_configured_marketplace() {
let root = TempDir::new().expect("tempdir");
let marketplace_root = root.path().join("plugins/marketplaces/acme");
let manifest_path = marketplace_root.join(PLUGIN_MARKETPLACE_MANIFEST);
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
.expect("manifest directory");
fs::create_dir_all(root.path().join("plugins/cache/acme/sample")).expect("cached plugin");
fs::write(
&manifest_path,
r#"{"name":"acme","plugins":[{"name":"sample"}]}"#,
)
.expect("marketplace manifest");
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_root = root.path().join("repo");
let detected = detect_plugins(&PluginDetectionContext {
external_agent_home: root.path(),
source_settings: &source_settings,
source_root: &source_root,
repo_root: None,
settings: None,
configured_plugin_ids: &configured_plugin_ids,
configured_marketplace_plugins: &configured_marketplace_plugins,
})
.expect("detect plugins")
.expect("plugin migration");
assert_eq!(
detected.details,
MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme".to_string(),
plugin_names: vec!["sample".to_string()],
}],
..Default::default()
}
);
}
#[test]
fn detects_legacy_repo_instruction_file() {
let root = TempDir::new().expect("tempdir");
let source = root.path().join(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"),
vec![InstructionSourceGroup {
scope: root.path().to_path_buf(),
sources: vec![source.clone()],
}]
);
assert_eq!(
read_instruction_source(&source).expect("instruction contents"),
"Use the source agent carefully.\n"
);
}
@@ -2545,6 +2545,121 @@ async fn import_plugins_supports_external_agent_plugin_marketplace_layout() {
assert!(config.contains("enabled = true")); 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] #[tokio::test]
async fn detect_home_supports_relative_external_agent_plugin_marketplace_path() { async fn detect_home_supports_relative_external_agent_plugin_marketplace_path() {
let (_root, external_agent_home, codex_home) = fixture_paths(); let (_root, external_agent_home, codex_home) = fixture_paths();
@@ -10,8 +10,9 @@ use codex_external_agent_migration::sessions::ExternalAgentSessionMigration;
use codex_external_agent_migration::sessions::ImportedExternalAgentSession; use codex_external_agent_migration::sessions::ImportedExternalAgentSession;
use codex_external_agent_migration::sessions::ImportedSessionConnectorAttribution; use codex_external_agent_migration::sessions::ImportedSessionConnectorAttribution;
use codex_external_agent_migration::sessions::PendingSessionImport; use codex_external_agent_migration::sessions::PendingSessionImport;
use codex_external_agent_migration::sessions::SessionMetadataMode;
use codex_external_agent_migration::sessions::detect_imported_cla_session_connectors; use codex_external_agent_migration::sessions::detect_imported_cla_session_connectors;
use codex_external_agent_migration::sessions::prepare_validated_session_import; use codex_external_agent_migration::sessions::prepare_validated_session_import_with_metadata_mode;
use codex_external_agent_migration::sessions::record_completed_session_imports; use codex_external_agent_migration::sessions::record_completed_session_imports;
use codex_models_manager::manager::RefreshStrategy; use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::ThreadId; use codex_protocol::ThreadId;
@@ -75,6 +76,7 @@ impl ExternalAgentSessionImporter {
&self, &self,
sessions: Vec<ExternalAgentSessionMigration>, sessions: Vec<ExternalAgentSessionMigration>,
mut item_result: ExternalAgentConfigImportItemResult, mut item_result: ExternalAgentConfigImportItemResult,
metadata_mode: SessionMetadataMode,
) -> ExternalAgentConfigImportItemResult { ) -> ExternalAgentConfigImportItemResult {
if sessions.is_empty() { if sessions.is_empty() {
return item_result; return item_result;
@@ -91,7 +93,11 @@ impl ExternalAgentSessionImporter {
let import_results = futures::stream::iter(sessions) let import_results = futures::stream::iter(sessions)
.map(|session| { .map(|session| {
let importer = self.clone(); let importer = self.clone();
async move { importer.import_requested_session(session).await } async move {
importer
.import_requested_session(session, metadata_mode)
.await
}
}) })
.buffer_unordered(SESSION_IMPORT_CONCURRENCY); .buffer_unordered(SESSION_IMPORT_CONCURRENCY);
futures::pin_mut!(import_results); futures::pin_mut!(import_results);
@@ -167,16 +173,17 @@ impl ExternalAgentSessionImporter {
async fn import_requested_session( async fn import_requested_session(
&self, &self,
session: ExternalAgentSessionMigration, session: ExternalAgentSessionMigration,
metadata_mode: SessionMetadataMode,
) -> Result<Option<CompletedSessionImport>, SessionImportFailure> { ) -> Result<Option<CompletedSessionImport>, SessionImportFailure> {
let source_path = session.path.clone(); let source_path = session.path.clone();
let Some(pending_import) = let Some(pending_import) = self
self.prepare_session_import(session) .prepare_session_import(session, metadata_mode)
.await .await
.map_err(|message| SessionImportFailure { .map_err(|message| SessionImportFailure {
source_path: source_path.clone(), source_path: source_path.clone(),
message, message,
stage: "session_prepare", stage: "session_prepare",
})? })?
else { else {
return Ok(None); return Ok(None);
}; };
@@ -212,12 +219,15 @@ impl ExternalAgentSessionImporter {
async fn prepare_session_import( async fn prepare_session_import(
&self, &self,
session: ExternalAgentSessionMigration, session: ExternalAgentSessionMigration,
metadata_mode: SessionMetadataMode,
) -> Result<Option<PendingSessionImport>, String> { ) -> Result<Option<PendingSessionImport>, String> {
let codex_home = self.codex_home.clone(); let codex_home = self.codex_home.clone();
tokio::task::spawn_blocking(move || prepare_validated_session_import(&codex_home, session)) tokio::task::spawn_blocking(move || {
.await prepare_validated_session_import_with_metadata_mode(&codex_home, session, metadata_mode)
.map_err(|err| format!("external agent session preparation task failed: {err}"))? })
.map_err(|err| format!("failed to prepare external agent session: {err}")) .await
.map_err(|err| format!("external agent session preparation task failed: {err}"))?
.map_err(|err| format!("failed to prepare external agent session: {err}"))
} }
async fn persist_session( async fn persist_session(
@@ -43,6 +43,7 @@ use tokio::time::timeout;
use super::analytics::wait_for_analytics_event; use super::analytics::wait_for_analytics_event;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
const SECONDARY_MIGRATION_SOURCE: &str = concat!("cur", "sor");
fn external_agent_home(codex_home: &Path) -> PathBuf { fn external_agent_home(codex_home: &Path) -> PathBuf {
codex_home.join(concat!(".", "cla", "ude")) codex_home.join(concat!(".", "cla", "ude"))
@@ -63,19 +64,22 @@ fn connector_metadata_root(home: &Path) -> PathBuf {
} }
} }
fn secondary_external_agent_home(codex_home: &Path) -> PathBuf {
codex_home.join(concat!(".", "cur", "sor"))
}
fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String { fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String {
assert!(!response.import_id.is_empty()); assert!(!response.import_id.is_empty());
response.import_id response.import_id
} }
#[tokio::test] #[tokio::test]
async fn external_agent_config_detect_accepts_source_selection_and_defaults_unknown_values() async fn external_agent_config_detect_accepts_migration_source_and_defaults_unknown_values()
-> Result<()> { -> Result<()> {
let codex_home = TempDir::new()?; let codex_home = TempDir::new()?;
let source_home = external_agent_home(codex_home.path()); let source_home = external_agent_home(codex_home.path());
std::fs::create_dir_all(&source_home)?; std::fs::create_dir_all(&source_home)?;
std::fs::write(source_home.join("CLAUDE.md"), "project instructions")?; std::fs::write(source_home.join("CLAUDE.md"), "project instructions")?;
let home_dir = codex_home.path().display().to_string(); let home_dir = codex_home.path().display().to_string();
let mut mcp = TestAppServer::builder() let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path()) .with_codex_home(codex_home.path())
@@ -86,11 +90,21 @@ async fn external_agent_config_detect_accepts_source_selection_and_defaults_unkn
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let mut responses = Vec::new(); let mut responses = Vec::new();
for source in [None, Some("claude-code"), Some("unknown-source")] { for params in [
let mut params = serde_json::json!({ "includeHome": true }); serde_json::json!({ "includeHome": true }),
if let Some(source) = source { serde_json::json!({
params["source"] = serde_json::json!(source); "includeHome": true,
} "migrationSource": "claude-code",
}),
serde_json::json!({
"includeHome": true,
"migrationSource": "unknown-source",
}),
serde_json::json!({
"includeHome": true,
"source": SECONDARY_MIGRATION_SOURCE,
}),
] {
let request_id = mcp let request_id = mcp
.send_raw_request("externalAgentConfig/detect", Some(params)) .send_raw_request("externalAgentConfig/detect", Some(params))
.await?; .await?;
@@ -108,7 +122,411 @@ async fn external_agent_config_detect_accepts_source_selection_and_defaults_unkn
ExternalAgentConfigMigrationItemType::AgentsMd ExternalAgentConfigMigrationItemType::AgentsMd
); );
let expected = responses[0].clone(); let expected = responses[0].clone();
assert_eq!(responses, vec![expected; 3]); assert_eq!(responses, vec![expected; 4]);
Ok(())
}
#[tokio::test]
async fn external_agent_config_migration_source_drives_detect_and_import() -> Result<()> {
let codex_home = TempDir::new()?;
let source_home = secondary_external_agent_home(codex_home.path());
std::fs::create_dir_all(&source_home)?;
std::fs::write(source_home.join("sandbox.json"), r#"{"type":"read_only"}"#)?;
let home_dir = codex_home.path().display().to_string();
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("HOME", Some(home_dir.as_str()))])
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"externalAgentConfig/detect",
Some(serde_json::json!({
"includeHome": true,
"migrationSource": SECONDARY_MIGRATION_SOURCE,
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let detected: ExternalAgentConfigDetectResponse = to_response(response)?;
assert_eq!(detected.items.len(), 1);
assert_eq!(
detected.items[0].item_type,
ExternalAgentConfigMigrationItemType::Config
);
let request_id = mcp
.send_raw_request(
"externalAgentConfig/import",
Some(serde_json::json!({
"migrationSource": SECONDARY_MIGRATION_SOURCE,
"migrationItems": detected.items,
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: ExternalAgentConfigImportResponse = to_response(response)?;
let import_id = assert_import_response(response);
let notification = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"),
)
.await??;
let completed: ExternalAgentConfigImportCompletedNotification =
serde_json::from_value(notification.params.expect("completed params"))?;
assert_eq!(completed.import_id, import_id);
assert_eq!(completed.item_type_results.len(), 1);
assert_eq!(completed.item_type_results[0].successes.len(), 1);
assert_eq!(completed.item_type_results[0].failures, Vec::new());
assert!(
std::fs::read_to_string(codex_home.path().join("config.toml"))?
.contains("sandbox_mode = \"read-only\"")
);
Ok(())
}
#[tokio::test]
async fn external_agent_config_import_source_remains_attribution_only() -> Result<()> {
let codex_home = TempDir::new()?;
let source_home = external_agent_home(codex_home.path());
std::fs::create_dir_all(&source_home)?;
std::fs::write(source_home.join("CLAUDE.md"), "Claude guidance")?;
let home_dir = codex_home.path().display().to_string();
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("HOME", Some(home_dir.as_str()))])
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"externalAgentConfig/detect",
Some(serde_json::json!({ "includeHome": true })),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let detected: ExternalAgentConfigDetectResponse = to_response(response)?;
assert_eq!(detected.items.len(), 1);
assert_eq!(
detected.items[0].item_type,
ExternalAgentConfigMigrationItemType::AgentsMd
);
let request_id = mcp
.send_raw_request(
"externalAgentConfig/import",
Some(serde_json::json!({
"source": SECONDARY_MIGRATION_SOURCE,
"migrationItems": detected.items,
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: ExternalAgentConfigImportResponse = to_response(response)?;
let import_id = assert_import_response(response);
let notification = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"),
)
.await??;
let completed: ExternalAgentConfigImportCompletedNotification =
serde_json::from_value(notification.params.expect("completed params"))?;
assert_eq!(completed.import_id, import_id);
assert_eq!(completed.item_type_results.len(), 1);
assert_eq!(completed.item_type_results[0].successes.len(), 1);
assert_eq!(completed.item_type_results[0].failures, Vec::new());
assert_eq!(
std::fs::read_to_string(codex_home.path().join("AGENTS.md"))?,
"Codex guidance"
);
Ok(())
}
#[tokio::test]
async fn external_agent_config_secondary_source_imports_session_and_plugin_end_to_end() -> Result<()>
{
let codex_home = TempDir::new()?;
let source_home = secondary_external_agent_home(codex_home.path());
let project_root = codex_home.path().join("workspace with.dots_and-dashes");
std::fs::create_dir_all(&project_root)?;
let encoded_project = project_root
.to_string_lossy()
.trim_start_matches(['/', '\\'])
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'-'
}
})
.collect::<String>();
#[cfg(windows)]
let encoded_project = encoded_project.replacen("--", "-", /*count*/ 1);
let session_path = source_home
.join("projects")
.join(encoded_project)
.join("agent-transcripts/session-1/session-1.jsonl");
std::fs::create_dir_all(session_path.parent().expect("session parent"))?;
std::fs::write(
&session_path,
[
serde_json::json!({
"role": "user",
"message": {
"content": [{
"type": "text",
"text": "<user_query>first request</user_query>"
}]
}
})
.to_string(),
serde_json::json!({
"role": "assistant",
"message": {
"content": [{"type": "text", "text": "first answer"}]
}
})
.to_string(),
]
.join("\n"),
)?;
let marketplace_root = source_home.join("plugins/marketplaces/debug");
let plugin_root = marketplace_root.join("plugins/sample");
let configured_marketplace_root = codex_home.path().join("configured-marketplace");
let configured_marketplace_manifest =
configured_marketplace_root.join(".agents/plugins/marketplace.json");
let configured_plugin_root = configured_marketplace_root.join("plugins/sample");
std::fs::create_dir_all(marketplace_root.join(".cursor-plugin"))?;
std::fs::create_dir_all(plugin_root.join(".cursor-plugin"))?;
std::fs::create_dir_all(source_home.join("plugins/cache/debug/sample"))?;
std::fs::create_dir_all(
configured_marketplace_manifest
.parent()
.expect("configured marketplace manifest parent"),
)?;
std::fs::create_dir_all(configured_plugin_root.join(".codex-plugin"))?;
std::fs::write(
marketplace_root.join(".cursor-plugin/marketplace.json"),
r#"{
"name": "debug",
"plugins": [{"name": "sample", "source": "plugins/sample"}]
}"#,
)?;
std::fs::write(
plugin_root.join(".cursor-plugin/plugin.json"),
r#"{"name":"sample","version":"0.2.0"}"#,
)?;
std::fs::write(
&configured_marketplace_manifest,
r#"{
"name": "debug",
"plugins": [{
"name": "sample",
"source": {"source": "local", "path": "./plugins/sample"}
}]
}"#,
)?;
std::fs::write(
configured_plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample","version":"0.1.0"}"#,
)?;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"[marketplaces.debug]
source_type = "local"
source = {:?}
"#,
configured_marketplace_root.display().to_string()
),
)?;
let home_dir = codex_home.path().display().to_string();
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[("HOME", Some(home_dir.as_str()))])
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"externalAgentConfig/detect",
Some(serde_json::json!({
"includeHome": true,
"migrationSource": SECONDARY_MIGRATION_SOURCE,
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let detected: ExternalAgentConfigDetectResponse = to_response(response)?;
assert_eq!(detected.items.len(), 2);
assert!(
detected
.items
.iter()
.any(|item| item.item_type == ExternalAgentConfigMigrationItemType::Sessions)
);
assert!(
detected
.items
.iter()
.any(|item| item.item_type == ExternalAgentConfigMigrationItemType::Plugins)
);
let request_id = mcp
.send_raw_request(
"externalAgentConfig/import",
Some(serde_json::json!({
"migrationSource": SECONDARY_MIGRATION_SOURCE,
"migrationItems": detected.items,
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: ExternalAgentConfigImportResponse = to_response(response)?;
let import_id = assert_import_response(response);
let notification = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"),
)
.await??;
let completed: ExternalAgentConfigImportCompletedNotification =
serde_json::from_value(notification.params.expect("completed params"))?;
assert_eq!(completed.import_id, import_id);
assert_eq!(completed.item_type_results.len(), 2);
assert!(
completed
.item_type_results
.iter()
.all(|result| result.failures.is_empty())
);
let request_id = mcp
.send_thread_list_request(ThreadListParams {
cursor: None,
limit: None,
sort_key: None,
sort_direction: None,
model_providers: None,
source_kinds: None,
archived: None,
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: ThreadListResponse = to_response(response)?;
let thread = response.data.first().expect("imported session");
assert_eq!(thread.cwd.as_path(), project_root);
assert_eq!(thread.preview, "first request");
assert_eq!(thread.name, None);
let request_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: thread.id.clone(),
include_turns: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: ThreadReadResponse = to_response(response)?;
assert_eq!(response.thread.turns.len(), 1);
let imported_items = &response.thread.turns[0].items;
assert_eq!(imported_items.len(), 3);
match &imported_items[0] {
ThreadItem::UserMessage { content, .. } => assert_eq!(
content,
&vec![UserInput::Text {
text: "first request".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected user message item, got {other:?}"),
}
match &imported_items[1] {
ThreadItem::AgentMessage { text, .. } => assert_eq!(text, "first answer"),
other => panic!("expected agent message item, got {other:?}"),
}
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
let marketplace = response
.marketplaces
.iter()
.find(|marketplace| marketplace.name == "debug")
.expect("configured marketplace");
assert_eq!(
marketplace
.path
.as_ref()
.map(codex_config::AbsolutePathBuf::as_path),
Some(configured_marketplace_manifest.as_path())
);
let plugin = marketplace
.plugins
.iter()
.find(|plugin| plugin.name == "sample")
.expect("imported plugin");
assert_eq!(plugin.local_version.as_deref(), Some("0.1.0"));
assert!(plugin.installed);
assert!(plugin.enabled);
Ok(()) Ok(())
} }
+8 -1
View File
@@ -21,6 +21,7 @@ const MARKETPLACE_MANIFEST_RELATIVE_PATHS: &[&str] = &[
".agents/plugins/marketplace.json", ".agents/plugins/marketplace.json",
".agents/plugins/api_marketplace.json", ".agents/plugins/api_marketplace.json",
".claude-plugin/marketplace.json", ".claude-plugin/marketplace.json",
".cursor-plugin/marketplace.json",
]; ];
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -662,7 +663,13 @@ fn resolve_local_plugin_source_path(
} }
// Non-root local sources must keep the explicit `./` prefix and remain normalized. // Non-root local sources must keep the explicit `./` prefix and remain normalized.
let Some(relative_path) = path.strip_prefix("./") else { let relative_path = path.strip_prefix("./").or_else(|| {
marketplace_path
.as_path()
.ends_with(".cursor-plugin/marketplace.json")
.then_some(path)
});
let Some(relative_path) = relative_path else {
return Err(MarketplaceError::InvalidMarketplaceFile { return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(), path: marketplace_path.to_path_buf(),
message: "local plugin source path must start with `./`".to_string(), message: "local plugin source path must start with `./`".to_string(),
@@ -6,6 +6,8 @@ use tempfile::tempdir;
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json"; const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json"; const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
const CUR_MARKETPLACE_RELATIVE_PATH: &str = ".cursor-plugin/marketplace.json";
const CUR_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".cursor-plugin/plugin.json";
fn write_alternate_marketplace(repo_root: &Path, contents: &str) -> AbsolutePathBuf { fn write_alternate_marketplace(repo_root: &Path, contents: &str) -> AbsolutePathBuf {
let marketplace_path = repo_root.join(ALTERNATE_MARKETPLACE_RELATIVE_PATH); let marketplace_path = repo_root.join(ALTERNATE_MARKETPLACE_RELATIVE_PATH);
fs::create_dir_all(marketplace_path.parent().unwrap()).unwrap(); fs::create_dir_all(marketplace_path.parent().unwrap()).unwrap();
@@ -19,6 +21,19 @@ fn write_alternate_plugin_manifest(plugin_root: &Path, contents: &str) {
fs::write(manifest_path, contents).unwrap(); fs::write(manifest_path, contents).unwrap();
} }
fn write_cur_marketplace(repo_root: &Path, contents: &str) -> AbsolutePathBuf {
let marketplace_path = repo_root.join(CUR_MARKETPLACE_RELATIVE_PATH);
fs::create_dir_all(marketplace_path.parent().unwrap()).unwrap();
fs::write(&marketplace_path, contents).unwrap();
AbsolutePathBuf::try_from(marketplace_path).unwrap()
}
fn write_cur_plugin_manifest(plugin_root: &Path, contents: &str) {
let manifest_path = plugin_root.join(CUR_PLUGIN_MANIFEST_RELATIVE_PATH);
fs::create_dir_all(manifest_path.parent().unwrap()).unwrap();
fs::write(manifest_path, contents).unwrap();
}
fn minimal_manifest_fallback(name: &str) -> MarketplacePluginManifestFallback { fn minimal_manifest_fallback(name: &str) -> MarketplacePluginManifestFallback {
MarketplacePluginManifestFallback { MarketplacePluginManifestFallback {
contents: format!( contents: format!(
@@ -124,6 +139,37 @@ fn find_marketplace_plugin_supports_alternate_layout_and_string_local_source() {
); );
} }
#[test]
fn find_marketplace_plugin_supports_cur_layout_and_bare_local_source() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("plugins/sample");
let marketplace_path = write_cur_marketplace(
&repo_root,
r#"{
"name": "secondary-marketplace",
"plugins": [{"name": "sample", "source": "plugins/sample"}]
}"#,
);
write_cur_plugin_manifest(&plugin_root, r#"{"name":"sample"}"#);
let resolved = find_marketplace_plugin(&marketplace_path, "sample").unwrap();
assert_eq!(
resolved.source,
MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(plugin_root).unwrap(),
}
);
assert_eq!(
resolved
.manifest
.as_ref()
.map(|manifest| manifest.name.as_str()),
Some("sample")
);
}
#[test] #[test]
fn find_marketplace_plugin_supports_git_subdir_sources() { fn find_marketplace_plugin_supports_git_subdir_sources() {
let tmp = tempdir().unwrap(); let tmp = tempdir().unwrap();
@@ -0,0 +1,174 @@
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;
use serde_json::Value as JsonValue;
use std::fs;
use std::io;
use std::path::Path;
pub fn hook_migration_event_names_cur(
source_external_agent_dir: &Path,
source_hooks: &Path,
target_hooks: &Path,
rewrite_profile: RewriteProfile,
) -> io::Result<Vec<String>> {
let migration = hook_migration_cur(
source_external_agent_dir,
source_hooks,
target_hooks.parent(),
rewrite_profile,
)?;
Ok(migration.keys().cloned().collect())
}
pub fn import_hooks_cur(
source_external_agent_dir: &Path,
source_hooks: &Path,
target_hooks: &Path,
rewrite_profile: RewriteProfile,
) -> io::Result<bool> {
let migration = hook_migration_cur(
source_external_agent_dir,
source_hooks,
target_hooks.parent(),
rewrite_profile,
)?;
write_hook_migration(source_external_agent_dir, target_hooks, migration)
}
fn hook_migration_cur(
source_external_agent_dir: &Path,
source_hooks: &Path,
target_config_dir: Option<&Path>,
rewrite_profile: RewriteProfile,
) -> io::Result<serde_json::Map<String, JsonValue>> {
if !source_hooks.is_file() {
return Ok(serde_json::Map::new());
}
let raw = fs::read_to_string(source_hooks)?;
let settings: JsonValue = serde_json::from_str(&raw)
.map_err(|err| invalid_data_error(format!("invalid hooks config: {err}")))?;
let Some(source_hooks) = settings.get("hooks").and_then(JsonValue::as_object) else {
return Ok(serde_json::Map::new());
};
let mut migration = serde_json::Map::new();
for (source_event_name, handlers) in source_hooks {
let Some(event_name) = compatible_hook_event_name(source_event_name) else {
continue;
};
let Some(handlers) = handlers.as_array() else {
continue;
};
for handler in handlers {
let Some(handler) = handler.as_object() else {
continue;
};
// Codex does not currently support a per-hook failure policy, so accept
// Cursor's `failClosed` field without copying it into the migrated handler.
if handler.keys().any(|key| {
!matches!(
key.as_str(),
"command"
| "failClosed"
| "matcher"
| "statusMessage"
| "timeout"
| "timeoutSec"
| "type"
)
}) {
continue;
}
let Some(command) = handler
.get("command")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|command| !command.is_empty())
else {
continue;
};
if handler
.get("type")
.and_then(JsonValue::as_str)
.is_some_and(|handler_type| handler_type != "command")
{
continue;
}
let mut command_payload = serde_json::Map::new();
command_payload.insert("type".to_string(), JsonValue::String("command".to_string()));
command_payload.insert(
"command".to_string(),
JsonValue::String(rewrite_hook_command_for_source(
command,
target_config_dir,
source_external_agent_dir,
)),
);
if let Some(timeout) = handler
.get("timeout")
.or_else(|| handler.get("timeoutSec"))
.and_then(json_u64)
{
command_payload.insert(
"timeout".to_string(),
JsonValue::Number(serde_json::Number::from(timeout)),
);
}
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,
)),
);
}
let mut group_payload = serde_json::Map::new();
if HOOK_EVENT_NAMES_WITH_MATCHERS.contains(&event_name)
&& let Some(matcher) = handler.get("matcher").and_then(JsonValue::as_str)
{
group_payload.insert(
"matcher".to_string(),
JsonValue::String(matcher.to_string()),
);
}
group_payload.insert(
"hooks".to_string(),
JsonValue::Array(vec![JsonValue::Object(command_payload)]),
);
let groups = migration
.entry(event_name.to_string())
.or_insert_with(|| JsonValue::Array(Vec::new()));
if let Some(groups) = groups.as_array_mut() {
groups.push(JsonValue::Object(group_payload));
}
}
}
Ok(migration)
}
fn compatible_hook_event_name(event_name: &str) -> Option<&'static str> {
match event_name {
"preToolUse" => Some("PreToolUse"),
"postToolUse" => Some("PostToolUse"),
"preCompact" => Some("PreCompact"),
"postCompact" => Some("PostCompact"),
"sessionStart" => Some("SessionStart"),
"subagentStart" => Some("SubagentStart"),
"subagentStop" => Some("SubagentStop"),
"beforeSubmitPrompt" => Some("UserPromptSubmit"),
"stop" => Some("Stop"),
_ => None,
}
}
#[cfg(test)]
#[path = "hooks_cur_tests.rs"]
mod tests;
@@ -0,0 +1,107 @@
use super::*;
use pretty_assertions::assert_eq;
const TEST_REWRITE_PROFILE: RewriteProfile =
RewriteProfile::new(".source-rules", &["source agent"]);
#[test]
fn imports_supported_cur_hooks_and_drops_failure_policy() {
let root = tempfile::TempDir::new().expect("tempdir");
let source_dir = root.path().join(".source");
let source_hooks_dir = source_dir.join("hooks");
let source_hooks = source_dir.join("hooks.json");
let target_hooks = root.path().join(".codex/hooks.json");
fs::create_dir_all(&source_hooks_dir).expect("source hooks directory");
fs::write(source_hooks_dir.join("check.sh"), "echo check\n").expect("hook script");
fs::write(
&source_hooks,
serde_json::json!({
"hooks": {
"preToolUse": [{
"type": "command",
"command": "sh .source/hooks/check.sh",
"matcher": "Shell",
"statusMessage": "Source agent check",
"timeoutSec": "7",
"failClosed": false
}],
"postToolUse": [{
"type": "prompt",
"command": "echo ignored"
}],
"subagentStart": [{
"command": "echo subagent",
"failClosed": true
}],
"beforeSubmitPrompt": [{
"command": "echo ready",
"matcher": "ignored"
}],
"preCompact": [{
"command": "echo compact",
"matcher": "auto"
}]
}
})
.to_string(),
)
.expect("hooks config");
assert!(
import_hooks_cur(
&source_dir,
&source_hooks,
&target_hooks,
TEST_REWRITE_PROFILE,
)
.expect("import hooks")
);
let target: JsonValue =
serde_json::from_str(&fs::read_to_string(&target_hooks).expect("target hooks"))
.expect("target hooks JSON");
let rewritten_script = target_hooks
.parent()
.expect("target hooks parent")
.join("hooks")
.join("check.sh");
assert_eq!(
target,
serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Shell",
"hooks": [{
"type": "command",
"command": format!("sh '{}'", rewritten_script.display()),
"timeout": 7,
"statusMessage": "Codex check"
}]
}],
"UserPromptSubmit": [{
"hooks": [{
"type": "command",
"command": "echo ready"
}]
}],
"PreCompact": [{
"matcher": "auto",
"hooks": [{
"type": "command",
"command": "echo compact"
}]
}],
"SubagentStart": [{
"hooks": [{
"type": "command",
"command": "echo subagent"
}]
}]
}
})
);
assert_eq!(
fs::read_to_string(rewritten_script).expect("copied hook script"),
"echo check\n"
);
}
@@ -1,6 +1,7 @@
//! Migration helpers for importing external-agent configuration into Codex. //! Migration helpers for importing external-agent configuration into Codex.
mod hooks_cla; mod hooks_cla;
mod hooks_cur;
pub mod sessions; pub mod sessions;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
@@ -16,6 +17,8 @@ use toml::Value as TomlValue;
pub use hooks_cla::hook_migration_event_names_cla; pub use hooks_cla::hook_migration_event_names_cla;
pub use hooks_cla::hooks_migration_description_cla; pub use hooks_cla::hooks_migration_description_cla;
pub use hooks_cla::import_hooks_cla; pub use hooks_cla::import_hooks_cla;
pub use hooks_cur::hook_migration_event_names_cur;
pub use hooks_cur::import_hooks_cur;
const SOURCE_EXTERNAL_AGENT_NAME: &str = "claude"; const SOURCE_EXTERNAL_AGENT_NAME: &str = "claude";
const EXTERNAL_AGENT_MCP_CONFIG_FILE: &str = ".mcp.json"; const EXTERNAL_AGENT_MCP_CONFIG_FILE: &str = ".mcp.json";
@@ -0,0 +1,211 @@
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;
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,
codex_home: &Path,
) -> io::Result<Vec<ExternalAgentSessionMigration>> {
let projects_root = external_agent_home.join("projects");
if !projects_root.is_dir() {
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);
for project_entry in fs::read_dir(projects_root)? {
let Ok(project_entry) = project_entry else {
continue;
};
let project_storage = project_entry.path();
if !project_storage.is_dir() {
continue;
}
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();
}
}
}
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)
}
fn cur_transcript_files(transcripts_root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let mut pending = vec![transcripts_root.to_path_buf()];
while let Some(directory) = pending.pop() {
let Ok(entries) = fs::read_dir(directory) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
if entry.file_name() != "subagents" {
pending.push(path);
}
} else if file_type.is_file()
&& path.extension().and_then(|extension| extension.to_str()) == Some("jsonl")
{
files.push(path);
}
}
}
files.sort();
files
}
fn cur_project_cwd(project_storage: &Path) -> Option<PathBuf> {
let encoded = project_storage.file_name()?.to_str()?;
decode_cur_project_path(encoded)
}
#[cfg(not(windows))]
fn decode_cur_project_path(encoded: &str) -> Option<PathBuf> {
let root = Path::new("/");
let mut matches = Vec::new();
collect_cur_project_paths(encoded, root, root, /*depth*/ 0, &mut matches);
if let Some(encoded) = encoded.strip_prefix('-') {
collect_cur_project_paths(encoded, root, root, /*depth*/ 0, &mut matches);
}
unique_path(matches)
}
#[cfg(windows)]
fn decode_cur_project_path(encoded: &str) -> Option<PathBuf> {
let drive = encoded.as_bytes().first().copied()?;
if !drive.is_ascii_alphabetic() || encoded.as_bytes().get(1) != Some(&b'-') {
return None;
}
let encoded = encoded.get(2..)?;
let base = PathBuf::from(format!("{}:\\", char::from(drive)));
let mut matches = Vec::new();
collect_cur_project_paths(encoded, &base, &base, /*depth*/ 0, &mut matches);
unique_path(matches)
}
fn collect_cur_project_paths(
encoded: &str,
base: &Path,
root: &Path,
depth: usize,
matches: &mut Vec<PathBuf>,
) {
if encoded.is_empty() || depth > 32 || matches.len() > 1 {
return;
}
let Ok(entries) = fs::read_dir(base) else {
return;
};
for entry in entries.flatten() {
if matches.len() > 1 {
break;
}
let candidate = entry.path();
if !candidate.is_dir() {
continue;
}
let Ok(candidate_from_root) = candidate.strip_prefix(root) else {
continue;
};
let candidate_slug = cur_project_path_slug(candidate_from_root);
if candidate_slug == encoded {
if !matches.contains(&candidate) {
matches.push(candidate);
}
} else if encoded
.strip_prefix(&candidate_slug)
.is_some_and(|remaining| remaining.starts_with('-'))
{
collect_cur_project_paths(encoded, &candidate, root, depth + 1, matches);
}
}
}
fn cur_project_path_slug(path: &Path) -> String {
path.to_string_lossy()
.trim_start_matches(['/', '\\'])
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'-'
}
})
.collect()
}
fn unique_path(mut matches: Vec<PathBuf>) -> Option<PathBuf> {
(matches.len() == 1).then(|| matches.swap_remove(0))
}
#[cfg(test)]
#[path = "detect_cur_tests.rs"]
mod tests;
@@ -0,0 +1,299 @@
use super::*;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use std::fs::FileTimes;
use std::fs::OpenOptions;
use std::time::SystemTime;
use tempfile::TempDir;
#[test]
fn detects_cur_transcript_with_project_cwd() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("workspace with.dots_and-dashes");
fs::create_dir_all(&project_root).expect("project root");
let external_agent_home = root.path().join(".external");
let encoded_project = encode_project_path(&project_root);
let transcript = write_transcript(
&external_agent_home,
&encoded_project,
"a-session",
"first request",
);
let sessions =
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions");
assert_eq!(
sessions,
vec![ExternalAgentSessionMigration {
path: transcript,
cwd: project_root,
title: Some("first request".to_string()),
}]
);
}
#[test]
fn detects_cur_transcript_with_embedded_unc_cwd() {
let root = TempDir::new().expect("tempdir");
let external_agent_home = root.path().join(".external");
let encoded_project = "server-share-repo";
let unc_cwd = PathBuf::from(r"\\server\share\repo");
let transcript = external_agent_home
.join("projects")
.join(encoded_project)
.join("agent-transcripts")
.join("unc-session/unc-session.jsonl");
fs::create_dir_all(transcript.parent().expect("transcript parent"))
.expect("transcript directory");
fs::write(
&transcript,
[
serde_json::json!({
"cwd": unc_cwd,
"role": "user",
"timestamp_ms": 1_800_000_000_000_i64,
"message": {
"content": [{
"type": "text",
"text": "<user_query>first request</user_query>",
}],
},
})
.to_string(),
serde_json::json!({
"role": "assistant",
"message": {
"content": [{"type": "text", "text": "first answer"}],
},
})
.to_string(),
]
.join("\n"),
)
.expect("transcript");
assert_eq!(
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions"),
vec![ExternalAgentSessionMigration {
path: transcript,
cwd: unc_cwd,
title: Some("first request".to_string()),
}]
);
}
#[test]
fn skips_cur_subagent_transcripts() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("workspace");
fs::create_dir_all(&project_root).expect("project root");
let external_agent_home = root.path().join(".external");
let encoded_project = encode_project_path(&project_root);
let transcript = write_transcript(
&external_agent_home,
&encoded_project,
"main-session",
"first request",
);
let subagent_transcript = external_agent_home
.join("projects")
.join(&encoded_project)
.join("agent-transcripts")
.join("main-session/subagents/worker/worker.jsonl");
fs::create_dir_all(
subagent_transcript
.parent()
.expect("subagent transcript parent"),
)
.expect("subagent transcript directory");
fs::write(&subagent_transcript, transcript_contents("first request"))
.expect("subagent transcript");
let sessions =
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions");
assert_eq!(
sessions,
vec![ExternalAgentSessionMigration {
path: transcript,
cwd: project_root,
title: Some("first request".to_string()),
}]
);
}
#[test]
fn rejects_ambiguous_encoded_project_cwd() {
let root = TempDir::new().expect("tempdir");
let nested_project = root.path().join("workspace").join("nested");
let hyphenated_project = root.path().join("workspace-nested");
fs::create_dir_all(&nested_project).expect("nested project");
fs::create_dir_all(&hyphenated_project).expect("hyphenated project");
assert_eq!(
decode_cur_project_path(&encode_project_path(&nested_project)),
None
);
}
#[test]
fn ignores_cur_sessions_older_than_import_window() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("workspace");
fs::create_dir_all(&project_root).expect("project root");
let external_agent_home = root.path().join(".external");
let transcript = write_transcript(
&external_agent_home,
&encode_project_path(&project_root),
"old-session",
"old request",
);
set_modified_at(
&transcript,
SystemTime::UNIX_EPOCH + Duration::from_secs(/*secs*/ 1),
);
assert!(
detect_recent_cur_sessions(&external_agent_home, root.path())
.expect("detect sessions")
.is_empty()
);
}
#[test]
fn detects_cur_sessions_in_batches_and_redetects_modified_imports() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("workspace");
fs::create_dir_all(&project_root).expect("project root");
let external_agent_home = root.path().join(".external");
let encoded_project = encode_project_path(&project_root);
let modified_at = SystemTime::now();
let mut expected = Vec::new();
for index in 0..=SESSION_IMPORT_MAX_COUNT {
let session_id = format!("session-{index:02}");
let title = format!("request {index}");
let path = write_transcript(&external_agent_home, &encoded_project, &session_id, &title);
set_modified_at(
&path,
modified_at - Duration::from_secs(/*secs*/ index as u64),
);
expected.push(ExternalAgentSessionMigration {
path,
cwd: project_root.clone(),
title: Some(title),
});
}
let oldest_session = expected.pop().expect("oldest session");
let sessions =
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions");
assert_eq!(sessions, expected);
for session in &sessions {
super::super::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(
root.path(),
&oldest_session.path,
ThreadId::new(),
)
.expect("record oldest import");
assert!(
detect_recent_cur_sessions(&external_agent_home, root.path())
.expect("detect sessions")
.is_empty()
);
let modified_session = &expected[0];
let updated_record = serde_json::json!({
"role": "assistant",
"message": {
"content": [{"type": "text", "text": "updated answer"}],
},
})
.to_string();
fs::write(
&modified_session.path,
format!(
"{}\n{updated_record}",
transcript_contents(modified_session.title.as_deref().expect("session title"))
),
)
.expect("update transcript");
set_modified_at(
&modified_session.path,
SystemTime::now() + Duration::from_secs(/*secs*/ 1),
);
assert_eq!(
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions"),
vec![modified_session.clone()]
);
}
fn write_transcript(
external_agent_home: &Path,
encoded_project: &str,
session_id: &str,
first_request: &str,
) -> PathBuf {
let transcript = external_agent_home
.join("projects")
.join(encoded_project)
.join("agent-transcripts")
.join(session_id)
.join(format!("{session_id}.jsonl"));
fs::create_dir_all(transcript.parent().expect("transcript parent"))
.expect("transcript directory");
fs::write(&transcript, transcript_contents(first_request)).expect("transcript");
transcript
}
fn transcript_contents(first_request: &str) -> String {
[
serde_json::json!({
"role": "user",
"message": {
"content": [{
"type": "text",
"text": format!("<user_query>{first_request}</user_query>"),
}],
},
})
.to_string(),
serde_json::json!({
"role": "assistant",
"message": {
"content": [{"type": "text", "text": "first answer"}],
},
})
.to_string(),
]
.join("\n")
}
fn set_modified_at(path: &Path, modified_at: SystemTime) {
OpenOptions::new()
.write(true)
.open(path)
.expect("open transcript")
.set_times(FileTimes::new().set_modified(modified_at))
.expect("set transcript modified time");
}
#[cfg(windows)]
fn encode_project_path(path: &Path) -> String {
cur_project_path_slug(path).replacen("--", "-", 1)
}
#[cfg(not(windows))]
fn encode_project_path(path: &Path) -> String {
cur_project_path_slug(path)
}
@@ -1,7 +1,7 @@
use super::ConversationMessage; use super::ConversationMessage;
use super::ImportedExternalAgentSession; use super::ImportedExternalAgentSession;
use super::MessageRole; use super::MessageRole;
use super::records::read_session_import; use super::records::read_session_import_with_cwd;
use super::summarize_for_label; use super::summarize_for_label;
use super::title::IMPORTED_SESSION_FALLBACK_TITLE; use super::title::IMPORTED_SESSION_FALLBACK_TITLE;
use super::title::SessionTitleCandidates; use super::title::SessionTitleCandidates;
@@ -26,14 +26,17 @@ const EXTERNAL_SESSION_IMPORTED_MARKER: &str = "<EXTERNAL SESSION IMPORTED>";
#[cfg(test)] #[cfg(test)]
fn load_session_for_import(path: &Path) -> io::Result<Option<ImportedExternalAgentSession>> { fn load_session_for_import(path: &Path) -> io::Result<Option<ImportedExternalAgentSession>> {
Ok(load_session_for_import_with_content_sha256(path)? Ok(
.map(|(session, _content_sha256, _attributed_mcp_server_ids)| session)) load_session_for_import_with_content_sha256(path, /*fallback_cwd*/ None)?
.map(|(session, _content_sha256, _attributed_mcp_server_ids)| session),
)
} }
pub(crate) fn load_session_for_import_with_content_sha256( pub(crate) fn load_session_for_import_with_content_sha256(
path: &Path, path: &Path,
fallback_cwd: Option<&Path>,
) -> io::Result<Option<(ImportedExternalAgentSession, String, BTreeSet<String>)>> { ) -> io::Result<Option<(ImportedExternalAgentSession, String, BTreeSet<String>)>> {
let parsed = read_session_import(path)?; let parsed = read_session_import_with_cwd(path, fallback_cwd)?;
let Some(cwd) = parsed.cwd else { let Some(cwd) = parsed.cwd else {
return Ok(None); return Ok(None);
}; };
@@ -2,6 +2,7 @@
mod connectors_cla; mod connectors_cla;
mod detect_cla; mod detect_cla;
mod detect_cur;
mod export; mod export;
mod ledger; mod ledger;
mod records; mod records;
@@ -16,6 +17,7 @@ use std::path::PathBuf;
pub use connectors_cla::ImportedSessionConnectorAttribution; pub use connectors_cla::ImportedSessionConnectorAttribution;
pub use connectors_cla::detect_imported_cla_session_connectors; pub use connectors_cla::detect_imported_cla_session_connectors;
pub use detect_cla::detect_recent_cla_sessions; pub use detect_cla::detect_recent_cla_sessions;
pub use detect_cur::detect_recent_cur_sessions;
use export::load_session_for_import_with_content_sha256; use export::load_session_for_import_with_content_sha256;
pub use ledger::CompletedExternalAgentSessionImport; pub use ledger::CompletedExternalAgentSessionImport;
pub use ledger::ImportedConnectorCandidate; pub use ledger::ImportedConnectorCandidate;
@@ -33,6 +35,15 @@ fn normalized_connector_display_name(name: Option<&str>) -> Option<String> {
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
} }
/// Selects whether session records must carry their own project metadata.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionMetadataMode {
/// Read the project path only from the session records.
Embedded,
/// Use the detected migration path when the session records omit a project path.
MigrationFallback,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAgentSessionMigration { pub struct ExternalAgentSessionMigration {
pub path: PathBuf, pub path: PathBuf,
@@ -59,18 +70,38 @@ pub struct PendingSessionImport {
pub fn prepare_validated_session_import( pub fn prepare_validated_session_import(
codex_home: &Path, codex_home: &Path,
session: ExternalAgentSessionMigration, session: ExternalAgentSessionMigration,
) -> io::Result<Option<PendingSessionImport>> {
prepare_validated_session_import_with_metadata_mode(
codex_home,
session,
SessionMetadataMode::Embedded,
)
}
pub fn prepare_validated_session_import_with_metadata_mode(
codex_home: &Path,
session: ExternalAgentSessionMigration,
metadata_mode: SessionMetadataMode,
) -> io::Result<Option<PendingSessionImport>> { ) -> io::Result<Option<PendingSessionImport>> {
let has_been_imported = has_current_session_been_imported(codex_home, &session.path)?; let has_been_imported = has_current_session_been_imported(codex_home, &session.path)?;
if has_been_imported { if has_been_imported {
return Ok(None); return Ok(None);
} }
load_importable_session(&session.path) load_importable_session(&session.path, &session.cwd, metadata_mode)
} }
fn load_importable_session(path: &Path) -> io::Result<Option<PendingSessionImport>> { fn load_importable_session(
path: &Path,
fallback_cwd: &Path,
metadata_mode: SessionMetadataMode,
) -> io::Result<Option<PendingSessionImport>> {
let source_path = std::fs::canonicalize(path)?; let source_path = std::fs::canonicalize(path)?;
let fallback_cwd = match metadata_mode {
SessionMetadataMode::Embedded => None,
SessionMetadataMode::MigrationFallback => Some(fallback_cwd),
};
let Some((imported_session, source_content_sha256, attributed_mcp_server_ids)) = let Some((imported_session, source_content_sha256, attributed_mcp_server_ids)) =
load_session_for_import_with_content_sha256(&source_path)? load_session_for_import_with_content_sha256(&source_path, fallback_cwd)?
else { else {
return Ok(None); return Ok(None);
}; };
@@ -180,6 +211,48 @@ mod tests {
); );
} }
#[test]
fn migration_fallback_metadata_is_opt_in() {
let root = TempDir::new().expect("tempdir");
let source_path = root.path().join("session.jsonl");
std::fs::write(
&source_path,
serde_json::json!({
"type": "message",
"role": "user",
"timestamp_ms": 1_782_817_200_000_i64,
"message": {"content": "first request"},
})
.to_string(),
)
.expect("session");
let migration = session_migration(&source_path);
assert!(
prepare_validated_session_import_with_metadata_mode(
root.path(),
migration.clone(),
SessionMetadataMode::Embedded,
)
.expect("embedded metadata mode")
.is_none()
);
let pending = prepare_validated_session_import_with_metadata_mode(
root.path(),
migration,
SessionMetadataMode::MigrationFallback,
)
.expect("fallback metadata mode")
.expect("pending import");
assert_eq!(pending.session.cwd, root.path());
assert_eq!(
pending.session.first_user_message.as_deref(),
Some("first request")
);
assert!(!pending.session.rollout_items.is_empty());
}
fn session_migration(path: &Path) -> ExternalAgentSessionMigration { fn session_migration(path: &Path) -> ExternalAgentSessionMigration {
ExternalAgentSessionMigration { ExternalAgentSessionMigration {
path: path.to_path_buf(), path: path.to_path_buf(),
@@ -36,7 +36,15 @@ pub(super) struct ParsedSessionImport {
} }
pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> { pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
summarize_session_with_cwd(path, /*fallback_cwd*/ None)
}
pub(crate) fn summarize_session_with_cwd(
path: &Path,
fallback_cwd: Option<&Path>,
) -> io::Result<Option<SessionSummary>> {
let file = File::open(path)?; let file = File::open(path)?;
let fallback_timestamp = fallback_cwd.and_then(|_| file_modified_at_seconds(&file));
let reader = BufReader::new(file); let reader = BufReader::new(file);
let mut cwd = None; let mut cwd = None;
let mut custom_title = None; let mut custom_title = None;
@@ -67,7 +75,8 @@ pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
if let Some(title) = ai_title_from_record(&record) { if let Some(title) = ai_title_from_record(&record) {
ai_title = Some(title.to_string()); ai_title = Some(title.to_string());
} }
let Some(message) = conversation_message_from_owned_record(&mut record) else { let Some(message) = conversation_message_from_owned_record(&mut record, fallback_timestamp)
else {
continue; continue;
}; };
saw_message = true; saw_message = true;
@@ -83,7 +92,7 @@ pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
} }
} }
let Some(cwd) = cwd else { let Some(cwd) = cwd.or_else(|| fallback_cwd.map(Path::to_path_buf)) else {
return Ok(None); return Ok(None);
}; };
if !saw_message { if !saw_message {
@@ -109,8 +118,12 @@ pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
})) }))
} }
pub(super) fn read_session_import(path: &Path) -> io::Result<ParsedSessionImport> { pub(super) fn read_session_import_with_cwd(
path: &Path,
fallback_cwd: Option<&Path>,
) -> io::Result<ParsedSessionImport> {
let file = File::open(path)?; let file = File::open(path)?;
let fallback_timestamp = fallback_cwd.and_then(|_| file_modified_at_seconds(&file));
let mut reader = BufReader::new(file); let mut reader = BufReader::new(file);
let mut cwd = None; let mut cwd = None;
let mut custom_title = None; let mut custom_title = None;
@@ -152,12 +165,14 @@ pub(super) fn read_session_import(path: &Path) -> io::Result<ParsedSessionImport
if let Some(title) = ai_title_from_record(&record) { if let Some(title) = ai_title_from_record(&record) {
ai_title = Some(title.to_string()); ai_title = Some(title.to_string());
} }
if let Some(message) = conversation_message_from_owned_record(&mut record) { if let Some(message) =
conversation_message_from_owned_record(&mut record, fallback_timestamp)
{
messages.push(message); messages.push(message);
} }
} }
Ok(ParsedSessionImport { Ok(ParsedSessionImport {
cwd, cwd: cwd.or_else(|| fallback_cwd.map(Path::to_path_buf)),
custom_title, custom_title,
ai_title, ai_title,
messages, messages,
@@ -182,9 +197,16 @@ fn title_from_record<'a>(record: &'a JsonValue, record_type: &str, field: &str)
.filter(|title| !title.is_empty()) .filter(|title| !title.is_empty())
} }
fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option<ConversationMessage> { fn conversation_message_from_owned_record(
let record_type = record.get("type")?.as_str()?; record: &mut JsonValue,
if record_type != "assistant" && record_type != "user" { fallback_timestamp: Option<i64>,
) -> Option<ConversationMessage> {
let record_type = record
.get("type")
.and_then(JsonValue::as_str)
.filter(|record_type| matches!(*record_type, "assistant" | "user"))
.or_else(|| record.get("role").and_then(JsonValue::as_str))?;
if !matches!(record_type, "assistant" | "user") {
return None; return None;
} }
if record.get("isMeta").and_then(JsonValue::as_bool) == Some(true) if record.get("isMeta").and_then(JsonValue::as_bool) == Some(true)
@@ -197,7 +219,14 @@ fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option<Conv
let timestamp = record let timestamp = record
.get("timestamp") .get("timestamp")
.and_then(JsonValue::as_str) .and_then(JsonValue::as_str)
.and_then(parse_timestamp); .and_then(parse_timestamp)
.or_else(|| {
record
.get("timestamp_ms")
.and_then(JsonValue::as_i64)
.map(|value| value / 1_000)
})
.or(fallback_timestamp);
let content = record.get_mut("message")?.get_mut("content")?.take(); let content = record.get_mut("message")?.get_mut("content")?.take();
let extracted = match content { let extracted = match content {
JsonValue::String(text) => { JsonValue::String(text) => {
@@ -211,17 +240,46 @@ fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option<Conv
} }
content => extract_message_text(&content)?, content => extract_message_text(&content)?,
}; };
let role = if is_assistant || extracted.only_tool_result {
MessageRole::Assistant
} else {
MessageRole::User
};
let text = if role == MessageRole::User {
unwrap_user_query(extracted.text)
} else {
extracted.text
};
Some(ConversationMessage { Some(ConversationMessage {
role: if is_assistant || extracted.only_tool_result { role,
MessageRole::Assistant text,
} else {
MessageRole::User
},
text: extracted.text,
timestamp, timestamp,
}) })
} }
fn unwrap_user_query(text: String) -> String {
let trimmed = text.trim();
let Some(inner) = trimmed
.strip_prefix("<user_query>")
.and_then(|inner| inner.strip_suffix("</user_query>"))
.map(str::trim)
.filter(|inner| !inner.is_empty())
else {
return text;
};
inner.to_string()
}
fn file_modified_at_seconds(file: &File) -> Option<i64> {
file.metadata()
.ok()?
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|duration| i64::try_from(duration.as_secs()).ok())
}
struct ExtractedMessage { struct ExtractedMessage {
text: String, text: String,
only_tool_result: bool, only_tool_result: bool,
@@ -379,7 +437,7 @@ mod tests {
"type": "user", "type": "user",
"cwd": root.path(), "cwd": root.path(),
"timestamp": "2026-06-03T12:00:00Z", "timestamp": "2026-06-03T12:00:00Z",
"message": { "content": "first request" }, "message": { "content": "<user_query>\nfirst request\n</user_query>" },
}) })
.to_string(), .to_string(),
"not json".to_string(), "not json".to_string(),
@@ -397,7 +455,8 @@ mod tests {
.join("\n"); .join("\n");
std::fs::write(&path, &contents).expect("session"); std::fs::write(&path, &contents).expect("session");
let parsed = read_session_import(&path).expect("parse session"); let parsed =
read_session_import_with_cwd(&path, /*fallback_cwd*/ None).expect("parse session");
assert_eq!(parsed.cwd.as_deref(), Some(root.path())); assert_eq!(parsed.cwd.as_deref(), Some(root.path()));
assert_eq!(parsed.custom_title.as_deref(), Some("custom title")); assert_eq!(parsed.custom_title.as_deref(), Some("custom title"));
@@ -410,6 +469,33 @@ mod tests {
); );
} }
#[test]
fn embedded_cwd_overrides_migration_fallback() {
let root = TempDir::new().expect("tempdir");
let embedded_cwd = root.path().join("embedded");
let fallback_cwd = root.path().join("fallback");
let path = root.path().join("session.jsonl");
std::fs::write(
&path,
serde_json::json!({
"cwd": embedded_cwd,
"role": "user",
"message": {"content": "first request"},
})
.to_string(),
)
.expect("session");
let parsed =
read_session_import_with_cwd(&path, Some(&fallback_cwd)).expect("parse session");
let summary = summarize_session_with_cwd(&path, Some(&fallback_cwd))
.expect("summarize session")
.expect("session summary");
assert_eq!(parsed.cwd.as_deref(), Some(embedded_cwd.as_path()));
assert_eq!(summary.migration.cwd, embedded_cwd);
}
#[test] #[test]
fn converts_tool_use_blocks_to_bounded_external_agent_tags() { fn converts_tool_use_blocks_to_bounded_external_agent_tags() {
let block = serde_json::json!({ let block = serde_json::json!({
+6 -5
View File
@@ -134,8 +134,7 @@ use uuid::Uuid;
const JSONRPC_INVALID_REQUEST: i64 = -32600; const JSONRPC_INVALID_REQUEST: i64 = -32600;
const JSONRPC_METHOD_NOT_FOUND: i64 = -32601; const JSONRPC_METHOD_NOT_FOUND: i64 = -32601;
pub(crate) const EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE: &str = pub(crate) const EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE: &str = "A previous external agent import is still running. Wait for it to finish before importing again.";
"A previous Claude Code import is still running. Wait for it to finish before importing again.";
const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update"; const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update";
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -411,12 +410,13 @@ impl AppServerSession {
self.client self.client
.request_typed(ClientRequest::ExternalAgentConfigDetect { request_id, params }) .request_typed(ClientRequest::ExternalAgentConfigDetect { request_id, params })
.await .await
.wrap_err("externalAgentConfig/detect failed during Claude Code import") .wrap_err("externalAgentConfig/detect failed during external agent import")
} }
pub(crate) async fn external_agent_config_import( pub(crate) async fn external_agent_config_import(
&mut self, &mut self,
migration_items: Vec<ExternalAgentConfigMigrationItem>, migration_items: Vec<ExternalAgentConfigMigrationItem>,
migration_source: String,
) -> Result<()> { ) -> Result<()> {
// Mark the import active before sending the request so a fast completion notification // Mark the import active before sending the request so a fast completion notification
// cannot arrive before the TUI records it. // cannot arrive before the TUI records it.
@@ -433,11 +433,12 @@ impl AppServerSession {
request_id, request_id,
params: ExternalAgentConfigImportParams { params: ExternalAgentConfigImportParams {
migration_items, migration_items,
source: Some("claude-code".to_string()), source: Some(migration_source.clone()),
migration_source: Some(migration_source),
}, },
}) })
.await .await
.wrap_err("externalAgentConfig/import failed during Claude Code import"); .wrap_err("externalAgentConfig/import failed during external agent import");
match response { match response {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(err) => { Err(err) => {
@@ -868,6 +868,31 @@ mod tests {
assert_snapshot!("external_agent_config_migration_customize", rendered); assert_snapshot!("external_agent_config_migration_customize", rendered);
} }
#[test]
fn secondary_source_customize_snapshot() {
let items = vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description:
"Migrate /Users/alex/.cursor/cli-config.json into /Users/alex/.codex/config.toml"
.to_string(),
cwd: None,
details: None,
}];
let mut screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
screen.customize();
let rendered = render_screen(&screen, /*width*/ 80, /*height*/ 18);
assert_snapshot!(
"external_agent_config_migration_secondary_source_customize",
rendered
);
}
#[test] #[test]
fn customize_action_snapshot() { fn customize_action_snapshot() {
let items = sample_items(); let items = sample_items();
@@ -78,15 +78,14 @@ impl WidgetRef for &ExternalAgentConfigMigrationScreen {
let error_height = u16::from(self.error.is_some()); let error_height = u16::from(self.error.is_some());
let intro_lines = match self.view { let intro_lines = match self.view {
MigrationView::Summary => vec![ MigrationView::Summary => vec![
Line::from("Bring over your setup, current project, and recent chats."), Line::from("Bring over supported setup from another coding agent."),
Line::from("Codex may add files to your current project folder."), Line::from("Codex may add files to your current project folder."),
Line::from("Your existing Claude Code setup will not be changed."), Line::from("Your existing setup will not be changed."),
Line::from("Standard Claude Chat data cannot be imported."),
], ],
MigrationView::Customize => vec![ MigrationView::Customize => vec![
Line::from("Choose the Claude Code items to import."), Line::from("Choose items to import."),
Line::from("Codex may add files to your current project folder."), Line::from("Codex may add files to your current project folder."),
Line::from("Your existing Claude Code setup will not be changed."), Line::from("Your existing setup will not be changed."),
], ],
}; };
let intro_height = intro_lines.len() as u16; let intro_height = intro_lines.len() as u16;
@@ -119,7 +118,7 @@ impl WidgetRef for &ExternalAgentConfigMigrationScreen {
.areas(inner_area); .areas(inner_area);
let title = match self.view { let title = match self.view {
MigrationView::Summary => "Import from Claude Code", MigrationView::Summary => "Import setup",
MigrationView::Customize => "Choose what to import", MigrationView::Customize => "Choose what to import",
}; };
let heading = Line::from(vec!["> ".into(), title.bold()]); let heading = Line::from(vec!["> ".into(), title.bold()]);
@@ -4,6 +4,8 @@ use crate::external_agent_config_migration::ExternalAgentConfigMigrationOutcome;
use crate::external_agent_config_migration::run_external_agent_config_migration_prompt; use crate::external_agent_config_migration::run_external_agent_config_migration_prompt;
use crate::external_agent_config_migration_model::external_agent_config_migration_item_count; use crate::external_agent_config_migration_model::external_agent_config_migration_item_count;
use crate::external_agent_config_migration_model::external_agent_config_migration_type_label; use crate::external_agent_config_migration_model::external_agent_config_migration_type_label;
use crate::external_agent_config_migration_source::ExternalAgentConfigMigrationSource;
use crate::external_agent_config_migration_source::run_external_agent_config_source_prompt;
use crate::legacy_core::config::Config; use crate::legacy_core::config::Config;
use crate::tui; use crate::tui;
use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigDetectParams;
@@ -14,9 +16,9 @@ use ratatui::prelude::Stylize as _;
use ratatui::text::Line; use ratatui::text::Line;
pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_NO_ITEMS_MESSAGE: &str = pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_NO_ITEMS_MESSAGE: &str =
"No Claude Code setup was found to import."; "No compatible setup was found to import.";
pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_REMOTE_UNAVAILABLE_MESSAGE: &str = "Import from Claude Code is unavailable in remote sessions. Start Codex locally and run /import."; pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_REMOTE_UNAVAILABLE_MESSAGE: &str = "Import from other apps is unavailable in remote sessions. Start Codex locally and run /import.";
pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_DAEMON_UNAVAILABLE_MESSAGE: &str = "Import from Claude Code is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import."; pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_DAEMON_UNAVAILABLE_MESSAGE: &str = "Import from other apps is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import.";
pub(crate) enum ExternalAgentConfigMigrationFlowOutcome { pub(crate) enum ExternalAgentConfigMigrationFlowOutcome {
Started(Vec<Line<'static>>), Started(Vec<Line<'static>>),
@@ -24,6 +26,57 @@ pub(crate) enum ExternalAgentConfigMigrationFlowOutcome {
Cancelled, Cancelled,
} }
struct DetectedExternalAgentConfigSource {
source: ExternalAgentConfigMigrationSource,
items: Vec<ExternalAgentConfigMigrationItem>,
}
#[derive(Default)]
struct ExternalAgentConfigDetection {
sources: Vec<DetectedExternalAgentConfigSource>,
errors: Vec<String>,
}
enum ExternalAgentConfigDetectionOutcome {
Sources(Vec<DetectedExternalAgentConfigSource>),
NoItems,
Failed(String),
}
impl ExternalAgentConfigDetection {
fn record_items(
&mut self,
source: ExternalAgentConfigMigrationSource,
items: Vec<ExternalAgentConfigMigrationItem>,
) {
if !items.is_empty() {
self.sources
.push(DetectedExternalAgentConfigSource { source, items });
}
}
fn record_error(
&mut self,
source: ExternalAgentConfigMigrationSource,
error: impl std::fmt::Display,
) {
self.errors.push(format!("{}: {error}", source.label()));
}
fn finish(self) -> ExternalAgentConfigDetectionOutcome {
if !self.sources.is_empty() {
ExternalAgentConfigDetectionOutcome::Sources(self.sources)
} else if self.errors.is_empty() {
ExternalAgentConfigDetectionOutcome::NoItems
} else {
ExternalAgentConfigDetectionOutcome::Failed(format!(
"Could not check for importable setup: {}",
self.errors.join("; ")
))
}
}
}
fn external_agent_config_migration_started_lines( fn external_agent_config_migration_started_lines(
selected_items: &[ExternalAgentConfigMigrationItem], selected_items: &[ExternalAgentConfigMigrationItem],
remaining_item_count: usize, remaining_item_count: usize,
@@ -89,7 +142,7 @@ fn external_agent_config_migration_started_lines(
let mut lines = vec![ let mut lines = vec![
vec![ vec![
"• ".dim(), "• ".dim(),
"Claude Code import started.".cyan(), "Import started.".cyan(),
" You can keep working while it finishes.".into(), " You can keep working while it finishes.".into(),
] ]
.into(), .into(),
@@ -145,7 +198,7 @@ pub(crate) fn external_agent_config_migration_finished_lines(
let mut lines = vec![ let mut lines = vec![
vec![ vec![
"• ".dim(), "• ".dim(),
"Claude Code import finished: ".into(), "Import finished: ".into(),
format!("{imported_count} imported").green(), format!("{imported_count} imported").green(),
", ".into(), ", ".into(),
failed_count, failed_count,
@@ -212,28 +265,59 @@ pub(crate) async fn handle_external_agent_config_migration_prompt(
} }
let cwd = config.cwd.to_path_buf(); let cwd = config.cwd.to_path_buf();
let detected_items = match app_server let mut detection = ExternalAgentConfigDetection::default();
.external_agent_config_detect(ExternalAgentConfigDetectParams { for source in ExternalAgentConfigMigrationSource::ALL {
include_home: true, let response = match app_server
cwds: Some(vec![cwd.clone()]), .external_agent_config_detect(ExternalAgentConfigDetectParams {
source: Some("claude-code".to_string()), include_home: true,
}) cwds: Some(vec![cwd.clone()]),
.await source: None,
{ migration_source: Some(source.migration_source().to_string()),
Ok(response) => response.items, })
Err(err) => { .await
tracing::warn!( {
error = %err, Ok(response) => response,
cwd = %cwd.display(), Err(err) => {
"failed to detect external agent config migrations" tracing::warn!(
); error = %err,
return Err(format!("Could not check for Claude Code setup: {err}")); cwd = %cwd.display(),
source = source.label(),
"failed to detect external agent config migrations"
);
detection.record_error(source, err);
continue;
}
};
detection.record_items(source, response.items);
}
let detected_sources = match detection.finish() {
ExternalAgentConfigDetectionOutcome::Sources(sources) => sources,
ExternalAgentConfigDetectionOutcome::NoItems => {
return Ok(ExternalAgentConfigMigrationFlowOutcome::NoItems);
} }
ExternalAgentConfigDetectionOutcome::Failed(error) => return Err(error),
}; };
if detected_items.is_empty() { let selected_source = if detected_sources.len() == 1 {
return Ok(ExternalAgentConfigMigrationFlowOutcome::NoItems); detected_sources[0].source
} } else {
let sources = detected_sources
.iter()
.map(|detected| detected.source)
.collect::<Vec<_>>();
let Some(source) = run_external_agent_config_source_prompt(tui, &sources).await else {
return Ok(ExternalAgentConfigMigrationFlowOutcome::Cancelled);
};
source
};
let Some(detected_source) = detected_sources
.into_iter()
.find(|detected| detected.source == selected_source)
else {
return Err("Selected import source is no longer available.".to_string());
};
let detected_items = detected_source.items;
let mut selected_items = detected_items.clone(); let mut selected_items = detected_items.clone();
let mut error: Option<String> = None; let mut error: Option<String> = None;
@@ -249,7 +333,13 @@ pub(crate) async fn handle_external_agent_config_migration_prompt(
{ {
ExternalAgentConfigMigrationOutcome::Proceed(items) => { ExternalAgentConfigMigrationOutcome::Proceed(items) => {
selected_items = items.clone(); selected_items = items.clone();
match app_server.external_agent_config_import(items).await { match app_server
.external_agent_config_import(
items,
selected_source.migration_source().to_string(),
)
.await
{
Ok(()) => { Ok(()) => {
let remaining_item_count = let remaining_item_count =
detected_items.len().saturating_sub(selected_items.len()); detected_items.len().saturating_sub(selected_items.len());
@@ -12,6 +12,15 @@ use pretty_assertions::assert_eq;
use ratatui::text::Line; use ratatui::text::Line;
use std::path::PathBuf; use std::path::PathBuf;
fn detected_item(description: &str) -> ExternalAgentConfigMigrationItem {
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: description.to_string(),
cwd: None,
details: None,
}
}
fn selected_items() -> Vec<ExternalAgentConfigMigrationItem> { fn selected_items() -> Vec<ExternalAgentConfigMigrationItem> {
vec![ vec![
ExternalAgentConfigMigrationItem { ExternalAgentConfigMigrationItem {
@@ -174,7 +183,7 @@ fn external_agent_config_migration_status_lines_use_semantic_colors() {
vec![ vec![
Line::from(vec![ Line::from(vec![
"• ".dim(), "• ".dim(),
"Claude Code import started.".cyan(), "Import started.".cyan(),
" You can keep working while it finishes.".into(), " You can keep working while it finishes.".into(),
]), ]),
Line::from(vec![ Line::from(vec![
@@ -228,7 +237,7 @@ fn external_agent_config_migration_status_lines_use_semantic_colors() {
vec![ vec![
Line::from(vec![ Line::from(vec![
"• ".dim(), "• ".dim(),
"Claude Code import finished: ".into(), "Import finished: ".into(),
"2 imported".green(), "2 imported".green(),
", ".into(), ", ".into(),
"1 failed".red(), "1 failed".red(),
@@ -258,3 +267,70 @@ fn external_agent_config_migration_status_lines_use_semantic_colors() {
] ]
); );
} }
#[test]
fn external_agent_config_detection_keeps_one_or_multiple_available_sources() {
let mut detection = ExternalAgentConfigDetection::default();
detection.record_items(
ExternalAgentConfigMigrationSource::Cla,
vec![detected_item("first")],
);
detection.record_items(ExternalAgentConfigMigrationSource::Cur, Vec::new());
let ExternalAgentConfigDetectionOutcome::Sources(sources) = detection.finish() else {
panic!("expected one detected source");
};
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].source, ExternalAgentConfigMigrationSource::Cla);
assert_eq!(sources[0].items, vec![detected_item("first")]);
let mut detection = ExternalAgentConfigDetection::default();
detection.record_items(
ExternalAgentConfigMigrationSource::Cla,
vec![detected_item("first")],
);
detection.record_items(
ExternalAgentConfigMigrationSource::Cur,
vec![detected_item("second")],
);
let ExternalAgentConfigDetectionOutcome::Sources(sources) = detection.finish() else {
panic!("expected two detected sources");
};
assert_eq!(sources.len(), 2);
assert_eq!(sources[0].source, ExternalAgentConfigMigrationSource::Cla);
assert_eq!(sources[1].source, ExternalAgentConfigMigrationSource::Cur);
}
#[test]
fn external_agent_config_detection_tolerates_a_partial_failure() {
let mut detection = ExternalAgentConfigDetection::default();
detection.record_error(ExternalAgentConfigMigrationSource::Cla, "unavailable");
detection.record_items(
ExternalAgentConfigMigrationSource::Cur,
vec![detected_item("available")],
);
let ExternalAgentConfigDetectionOutcome::Sources(sources) = detection.finish() else {
panic!("expected the available source");
};
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].source, ExternalAgentConfigMigrationSource::Cur);
}
#[test]
fn external_agent_config_detection_distinguishes_no_items_from_total_failure() {
assert!(matches!(
ExternalAgentConfigDetection::default().finish(),
ExternalAgentConfigDetectionOutcome::NoItems
));
let mut detection = ExternalAgentConfigDetection::default();
detection.record_error(ExternalAgentConfigMigrationSource::Cla, "first failure");
detection.record_error(ExternalAgentConfigMigrationSource::Cur, "second failure");
let ExternalAgentConfigDetectionOutcome::Failed(error) = detection.finish() else {
panic!("expected detection failure");
};
assert_eq!(
error,
"Could not check for importable setup: Claude Code: first failure; Cursor: second failure"
);
}
@@ -79,8 +79,8 @@ pub(crate) fn external_agent_config_migration_item_label(
item: &ExternalAgentConfigMigrationItem, item: &ExternalAgentConfigMigrationItem,
) -> &'static str { ) -> &'static str {
match item.item_type { match item.item_type {
ExternalAgentConfigMigrationItemType::AgentsMd => "Instructions (CLAUDE.md -> AGENTS.md)", ExternalAgentConfigMigrationItemType::AgentsMd => "Instructions",
ExternalAgentConfigMigrationItemType::Config => "Settings (settings.json -> config.toml)", ExternalAgentConfigMigrationItemType::Config => "Settings",
ExternalAgentConfigMigrationItemType::Skills => "Skills", ExternalAgentConfigMigrationItemType::Skills => "Skills",
ExternalAgentConfigMigrationItemType::Plugins => "Plugins", ExternalAgentConfigMigrationItemType::Plugins => "Plugins",
ExternalAgentConfigMigrationItemType::McpServerConfig => "MCP servers", ExternalAgentConfigMigrationItemType::McpServerConfig => "MCP servers",
@@ -0,0 +1,200 @@
use crate::key_hint;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Stylize as _;
use ratatui::prelude::Widget;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::WidgetRef;
use tokio_stream::StreamExt;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ExternalAgentConfigMigrationSource {
Cla,
Cur,
}
impl ExternalAgentConfigMigrationSource {
pub(crate) const ALL: [Self; 2] = [Self::Cla, Self::Cur];
pub(crate) fn migration_source(self) -> &'static str {
match self {
Self::Cla => "claude-code",
Self::Cur => "cursor",
}
}
pub(crate) fn label(self) -> &'static str {
match self {
Self::Cla => "Claude Code",
Self::Cur => "Cursor",
}
}
}
pub(crate) async fn run_external_agent_config_source_prompt(
tui: &mut Tui,
sources: &[ExternalAgentConfigMigrationSource],
) -> Option<ExternalAgentConfigMigrationSource> {
let mut screen = ExternalAgentConfigSourceScreen::new(tui.frame_requester(), sources);
let _ = tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});
let events = tui.event_stream();
tokio::pin!(events);
while !screen.is_done() {
if let Some(event) = events.next().await {
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) => {}
TuiEvent::Draw | TuiEvent::Resize => {
let _ = tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
});
}
}
} else {
screen.cancel();
}
}
screen.selection()
}
struct ExternalAgentConfigSourceScreen {
request_frame: FrameRequester,
sources: Vec<ExternalAgentConfigMigrationSource>,
highlighted_idx: usize,
selection: Option<ExternalAgentConfigMigrationSource>,
done: bool,
}
impl ExternalAgentConfigSourceScreen {
fn new(request_frame: FrameRequester, sources: &[ExternalAgentConfigMigrationSource]) -> Self {
Self {
request_frame,
sources: sources.to_vec(),
highlighted_idx: 0,
selection: None,
done: sources.is_empty(),
}
}
fn handle_key(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release || self.sources.is_empty() {
return;
}
if key_event.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))
{
self.cancel();
return;
}
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => self.move_highlight(/*delta*/ -1),
KeyCode::Down | KeyCode::Char('j') => self.move_highlight(/*delta*/ 1),
KeyCode::Char(character) if character.is_ascii_digit() => {
let Some(index) = character
.to_digit(10)
.and_then(|index| index.checked_sub(1))
else {
return;
};
self.select(index as usize);
}
KeyCode::Enter => self.select(self.highlighted_idx),
KeyCode::Esc => self.cancel(),
_ => {}
}
}
fn move_highlight(&mut self, delta: isize) {
self.highlighted_idx = self
.highlighted_idx
.saturating_add_signed(delta)
.min(self.sources.len().saturating_sub(1));
self.request_frame.schedule_frame();
}
fn select(&mut self, index: usize) {
let Some(source) = self.sources.get(index).copied() else {
return;
};
self.selection = Some(source);
self.done = true;
self.request_frame.schedule_frame();
}
fn cancel(&mut self) {
self.selection = None;
self.done = true;
self.request_frame.schedule_frame();
}
fn is_done(&self) -> bool {
self.done
}
fn selection(&self) -> Option<ExternalAgentConfigMigrationSource> {
self.selection
}
}
impl WidgetRef for &ExternalAgentConfigSourceScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let mut column = ColumnRenderable::new();
column.push("");
column.push("Choose an import source".bold());
column.push("");
column.push(
Line::from("Select the app whose setup you want to import.".dim()).inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
column.push("");
for (index, source) in self.sources.iter().enumerate() {
let prefix = if index == self.highlighted_idx {
"›"
} else {
" "
};
let label = Span::from(format!("{prefix} {}. {}", index + 1, source.label()));
column.push(Line::from(if index == self.highlighted_idx {
label.cyan()
} else {
label
}));
}
column.push("");
column.push(
Line::from(vec![
"Press ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" to continue".dim(),
])
.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
);
column.render(area, buf);
}
}
#[cfg(test)]
#[path = "external_agent_config_migration_source_tests.rs"]
mod tests;
@@ -0,0 +1,42 @@
use super::*;
use crate::test_backend::VT100Backend;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
fn new_screen() -> ExternalAgentConfigSourceScreen {
ExternalAgentConfigSourceScreen::new(
FrameRequester::test_dummy(),
&ExternalAgentConfigMigrationSource::ALL,
)
}
#[test]
fn external_agent_config_source_prompt_snapshot() {
let screen = new_screen();
let mut terminal =
Terminal::new(VT100Backend::new(/*width*/ 80, /*height*/ 12)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
.expect("render source prompt");
insta::assert_snapshot!("external_agent_config_source_prompt", terminal.backend());
}
#[test]
fn external_agent_config_source_prompt_selects_highlighted_source() {
let mut screen = new_screen();
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
screen.selection(),
Some(ExternalAgentConfigMigrationSource::Cur)
);
}
#[test]
fn external_agent_config_source_prompt_can_cancel() {
let mut screen = new_screen();
screen.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(screen.selection(), None);
}
+1
View File
@@ -116,6 +116,7 @@ mod exec_command;
mod external_agent_config_migration; mod external_agent_config_migration;
mod external_agent_config_migration_flow; mod external_agent_config_migration_flow;
mod external_agent_config_migration_model; mod external_agent_config_migration_model;
mod external_agent_config_migration_source;
mod external_editor; mod external_editor;
mod file_search; mod file_search;
mod frames; mod frames;
@@ -4,11 +4,11 @@ expression: rendered
--- ---
> Choose what to import > Choose what to import
Choose the Claude Code items to import. Choose items to import.
Codex may add files to your current project folder. Codex may add files to your current project folder.
Your existing Claude Code setup will not be changed. Your existing setup will not be changed.
Home Home
› [x] Settings (settings.json -> config.toml) › [x] Settings
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf… Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
[x] Recent chat sessions [x] Recent chat sessions
Import recent Claude Code sessions Import recent Claude Code sessions
@@ -21,7 +21,7 @@ expression: rendered
• team-marketplace: asana • team-marketplace: asana
• debug: sample • debug: sample
• +1 more marketplaces • +1 more marketplaces
[x] Instructions (CLAUDE.md -> AGENTS.md) [x] Instructions
Import CLAUDE.md to AGENTS.md Import CLAUDE.md to AGENTS.md
Selected 4 of 4 items. Selected 4 of 4 items.
@@ -4,11 +4,11 @@ expression: rendered
--- ---
> Choose what to import > Choose what to import
Choose the Claude Code items to import. Choose items to import.
Codex may add files to your current project folder. Codex may add files to your current project folder.
Your existing Claude Code setup will not be changed. Your existing setup will not be changed.
Home Home
[x] Settings (settings.json -> config.toml) [x] Settings
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf… Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
[x] Recent chat sessions [x] Recent chat sessions
Import recent Claude Code sessions Import recent Claude Code sessions
@@ -21,7 +21,7 @@ expression: rendered
• team-marketplace: asana • team-marketplace: asana
• debug: sample • debug: sample
• +1 more marketplaces • +1 more marketplaces
[x] Instructions (CLAUDE.md -> AGENTS.md) [x] Instructions
Import CLAUDE.md to AGENTS.md Import CLAUDE.md to AGENTS.md
Selected 4 of 4 items. Selected 4 of 4 items.
@@ -4,11 +4,11 @@ expression: rendered
--- ---
> Choose what to import > Choose what to import
Choose the Claude Code items to import. Choose items to import.
Codex may add files to your current project folder. Codex may add files to your current project folder.
Your existing Claude Code setup will not be changed. Your existing setup will not be changed.
Home Home
[x] Settings (settings.json -> config.toml) [x] Settings
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf… Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
[x] Recent chat sessions [x] Recent chat sessions
Import recent Claude Code sessions Import recent Claude Code sessions
@@ -21,7 +21,7 @@ expression: rendered
• team-marketplace: asana • team-marketplace: asana
• debug: sample • debug: sample
• +1 more marketplaces • +1 more marketplaces
[x] Instructions (CLAUDE.md -> AGENTS.md) [x] Instructions
Import CLAUDE.md to AGENTS.md Import CLAUDE.md to AGENTS.md
Selected 4 of 4 items. Selected 4 of 4 items.
@@ -4,11 +4,11 @@ expression: rendered
--- ---
> Choose what to import > Choose what to import
Choose the Claude Code items to import. Choose items to import.
Codex may add files to your current project folder. Codex may add files to your current project folder.
Your existing Claude Code setup will not be changed. Your existing setup will not be changed.
Home Home
› [x] Settings (settings.json -> config.toml) › [x] Settings
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf… Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
[x] Recent chat sessions [x] Recent chat sessions
Import recent Claude Code sessions Import recent Claude Code sessions
@@ -21,7 +21,7 @@ expression: rendered
• team-marketplace: asana • team-marketplace: asana
• debug: sample • debug: sample
• +1 more marketplaces • +1 more marketplaces
[x] Instructions (CLAUDE.md -> AGENTS.md) [x] Instructions
Import CLAUDE.md to AGENTS.md Import CLAUDE.md to AGENTS.md
Selected 4 of 4 items. Selected 4 of 4 items.
@@ -3,11 +3,10 @@ source: tui/src/external_agent_config_migration.rs
expression: rendered expression: rendered
--- ---
> Import from Claude Code > Import setup
Bring over your setup, current project, and recent chats. Bring over supported setup from another coding agent.
Codex may add files to your current project folder. Codex may add files to your current project folder.
Your existing Claude Code setup will not be changed. Your existing setup will not be changed.
Standard Claude Chat data cannot be imported.
[x] Tools & setup [x] Tools & setup
Settings, instructions, integrations, agents, commands, and skills Settings, instructions, integrations, agents, commands, and skills
Importing: Settings 1 Importing: Settings 1
@@ -3,11 +3,10 @@ source: tui/src/external_agent_config_migration.rs
expression: rendered expression: rendered
--- ---
> Import from Claude Code > Import setup
Bring over your setup, current project, and recent chats. Bring over supported setup from another coding agent.
Codex may add files to your current project folder. Codex may add files to your current project folder.
Your existing Claude Code setup will not be changed. Your existing setup will not be changed.
Standard Claude Chat data cannot be imported.
[x] Tools & setup [x] Tools & setup
Settings, instructions, integrations, agents, commands, and skills Settings, instructions, integrations, agents, commands, and skills
Importing: Settings 1 Importing: Settings 1
@@ -0,0 +1,16 @@
---
source: tui/src/external_agent_config_migration.rs
expression: rendered
---
> Choose what to import
Choose items to import.
Codex may add files to your current project folder.
Your existing setup will not be changed.
Home
› [x] Settings
Import /Users/alex/.cursor/cli-config.json into /Users/alex/.codex/co…
Selected 1 of 1 item.
1. Review selection
Use ↑/↓ to move, space to toggle, b to go back
@@ -2,7 +2,7 @@
source: tui/src/external_agent_config_migration_flow_tests.rs source: tui/src/external_agent_config_migration_flow_tests.rs
expression: messages expression: messages
--- ---
• Claude Code import started. You can keep working while it finishes. • Import started. You can keep working while it finishes.
Imported setup will apply to new chats. Imported setup will apply to new chats.
Importing: Importing:
Settings: 1 Settings: 1
@@ -10,7 +10,7 @@ expression: messages
MCP servers: 2 — docs, issues MCP servers: 2 — docs, issues
Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes
Plugins: 2 — formatter, reviewer Plugins: 2 — formatter, reviewer
• Claude Code import started. You can keep working while it finishes. • Import started. You can keep working while it finishes.
Imported setup will apply to new chats. Imported setup will apply to new chats.
Importing: Importing:
Settings: 1 Settings: 1
@@ -19,7 +19,7 @@ expression: messages
Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes
Plugins: 2 — formatter, reviewer Plugins: 2 — formatter, reviewer
1 additional item remains. After it finishes, run /import again to review it. 1 additional item remains. After it finishes, run /import again to review it.
• Claude Code import started. You can keep working while it finishes. • Import started. You can keep working while it finishes.
Imported setup will apply to new chats. Imported setup will apply to new chats.
Importing: Importing:
Settings: 1 Settings: 1
@@ -28,12 +28,12 @@ expression: messages
Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes
Plugins: 2 — formatter, reviewer Plugins: 2 — formatter, reviewer
2 additional items remain. After it finishes, run /import again to review them. 2 additional items remain. After it finishes, run /import again to review them.
• Claude Code import finished: 2 imported, 1 failed. • Import finished: 2 imported, 1 failed.
Results by type: Results by type:
Settings: 1 imported, 0 failed Settings: 1 imported, 0 failed
Plugins: 1 imported, 1 failed Plugins: 1 imported, 1 failed
Run /import again to check for additional items. Run /import again to check for additional items.
No Claude Code setup was found to import. No compatible setup was found to import.
Import from Claude Code is unavailable in remote sessions. Start Codex locally and run /import. Import from other apps is unavailable in remote sessions. Start Codex locally and run /import.
Import from Claude Code is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import. Import from other apps is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import.
A previous Claude Code import is still running. Wait for it to finish before importing again. A previous external agent import is still running. Wait for it to finish before importing again.
@@ -0,0 +1,13 @@
---
source: tui/src/external_agent_config_migration_source_tests.rs
expression: terminal.backend()
---
Choose an import source
Select the app whose setup you want to import.
› 1. Claude Code
2. Cursor
Press enter to continue
+28 -4
View File
@@ -7,8 +7,11 @@ use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
/// Ordered plugin manifest paths recognized beneath a plugin root. /// Ordered plugin manifest paths recognized beneath a plugin root.
pub const DISCOVERABLE_PLUGIN_MANIFEST_PATHS: &[&str] = pub const DISCOVERABLE_PLUGIN_MANIFEST_PATHS: &[&str] = &[
&[".codex-plugin/plugin.json", ".claude-plugin/plugin.json"]; ".codex-plugin/plugin.json",
".claude-plugin/plugin.json",
".cursor-plugin/plugin.json",
];
pub fn find_plugin_manifest_path(plugin_root: &Path) -> Option<PathBuf> { pub fn find_plugin_manifest_path(plugin_root: &Path) -> Option<PathBuf> {
DISCOVERABLE_PLUGIN_MANIFEST_PATHS DISCOVERABLE_PLUGIN_MANIFEST_PATHS
@@ -86,7 +89,8 @@ mod tests {
use std::fs; use std::fs;
use tempfile::tempdir; use tempfile::tempdir;
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json"; const ALTERNATE_PLUGIN_CLA_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
const ALTERNATE_PLUGIN_CUR_MANIFEST_RELATIVE_PATH: &str = ".cursor-plugin/plugin.json";
#[tokio::test] #[tokio::test]
async fn uses_manifest_name() { async fn uses_manifest_name() {
@@ -114,7 +118,27 @@ mod tests {
let tmp = tempdir().expect("tempdir"); let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("plugins/sample"); let plugin_root = tmp.path().join("plugins/sample");
let skill_path = plugin_root.join("skills/search/SKILL.md"); let skill_path = plugin_root.join("skills/search/SKILL.md");
let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH); let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_CLA_MANIFEST_RELATIVE_PATH);
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
.expect("mkdir manifest");
fs::write(&manifest_path, r#"{"name":"sample"}"#).expect("write manifest");
fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill");
assert_eq!(
plugin_namespace_for_skill_path(LOCAL_FS.as_ref(), &skill_path.abs()).await,
Some("sample".to_string())
);
assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path));
}
#[tokio::test]
async fn uses_name_from_cur_plugin_manifest_path() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("plugins/sample");
let skill_path = plugin_root.join("skills/search/SKILL.md");
let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_CUR_MANIFEST_RELATIVE_PATH);
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir"); fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
fs::create_dir_all(manifest_path.parent().expect("manifest parent")) fs::create_dir_all(manifest_path.parent().expect("manifest parent"))