diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 3cfbffe5623d..bc45f3f106c3 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -811,8 +811,15 @@ "description": "If true, include detection under the user's home directory.", "type": "boolean" }, + "migrationSource": { + "description": "Optional migration-source selector. Missing or unrecognized values use the default source.", + "type": [ + "string", + "null" + ] + }, "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": [ "string", "null" @@ -829,8 +836,15 @@ }, "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": { - "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": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 83b49189da5b..117de74f1a65 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9302,8 +9302,15 @@ "description": "If true, include detection under the user's home directory.", "type": "boolean" }, + "migrationSource": { + "description": "Optional migration-source selector. Missing or unrecognized values use the default source.", + "type": [ + "string", + "null" + ] + }, "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": [ "string", "null" @@ -9483,8 +9490,15 @@ }, "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": { - "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": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 7310fc388e62..5974535b2d27 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -5495,8 +5495,15 @@ "description": "If true, include detection under the user's home directory.", "type": "boolean" }, + "migrationSource": { + "description": "Optional migration-source selector. Missing or unrecognized values use the default source.", + "type": [ + "string", + "null" + ] + }, "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": [ "string", "null" @@ -5676,8 +5683,15 @@ }, "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": { - "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": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json index 51b698d7744c..20e1639ccd95 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json @@ -15,8 +15,15 @@ "description": "If true, include detection under the user's home directory.", "type": "boolean" }, + "migrationSource": { + "description": "Optional migration-source selector. Missing or unrecognized values use the default source.", + "type": [ + "string", + "null" + ] + }, "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": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json index 4b5b9b4d3b26..2c5b92f4d045 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json @@ -203,8 +203,15 @@ }, "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": { - "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": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts index 5bfdcfa19a6a..5d43fb440201 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts @@ -12,7 +12,11 @@ includeHome?: boolean, */ cwds?: Array | null, /** - * Optional import-source selector. Missing or unrecognized values use the default source for - * backwards compatibility. + * Deprecated field retained for compatibility. This field is ignored; use `migrationSource` + * 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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts index 87dcf98fcccb..8c28680b3da5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts @@ -5,8 +5,11 @@ import type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigr export type ExternalAgentConfigImportParams = { migrationItems: Array, /** - * 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. + * Optional identifier for the product that initiated the import. */ -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, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 72a4300ba479..eb989baf191d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -684,10 +684,13 @@ pub struct ExternalAgentConfigDetectParams { /// Zero or more working directories to include for repo-scoped detection. #[ts(optional = nullable)] pub cwds: Option>, - /// Optional import-source selector. Missing or unrecognized values use the default source for - /// backwards compatibility. + /// Deprecated field retained for compatibility. This field is ignored; use `migrationSource` + /// to select the migration source. #[ts(optional = nullable)] pub source: Option, + /// Optional migration-source selector. Missing or unrecognized values use the default source. + #[ts(optional = nullable)] + pub migration_source: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] @@ -695,11 +698,13 @@ pub struct ExternalAgentConfigDetectParams { #[ts(export_to = "v2/")] pub struct ExternalAgentConfigImportParams { pub migration_items: Vec, - /// 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. + /// Optional identifier for the product that initiated the import. #[ts(optional = nullable)] pub source: Option, + /// 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, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 915e37c5d72c..6901de3023ec 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -435,6 +435,7 @@ fn external_agent_config_import_params_accept_legacy_plugin_details() { }), }], source: None, + migration_source: None, } ); } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 07e6b0ff05f7..42ecb13528f1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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`. - `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`. -- `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/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/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. 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. - `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. diff --git a/codex-rs/app-server/src/external_agent_migration/processor.rs b/codex-rs/app-server/src/external_agent_migration/processor.rs index 5520b31f957c..20ae43deab5d 100644 --- a/codex-rs/app-server/src/external_agent_migration/processor.rs +++ b/codex-rs/app-server/src/external_agent_migration/processor.rs @@ -50,7 +50,6 @@ use super::service::ExternalAgentConfigMigrationItem as CoreMigrationItem; use super::service::ExternalAgentConfigMigrationItemType as CoreMigrationItemType; use super::service::ExternalAgentConfigService; use super::service::NamedMigration as CoreNamedMigration; -use super::service::PendingPluginImport; use super::service::PluginImportOutcome; use super::service::record_import_error; use super::session_importer::ExternalAgentSessionImporter; @@ -117,8 +116,10 @@ impl ExternalAgentConfigRequestProcessor { &self, params: ExternalAgentConfigDetectParams, ) -> Result { - let items = self + let migration_service = self .migration_service + .with_migration_source(params.migration_source.as_deref()); + let items = migration_service .detect(ExternalAgentConfigDetectOptions { include_home: params.include_home, cwds: params.cwds, @@ -219,6 +220,9 @@ impl ExternalAgentConfigRequestProcessor { ) -> Result<(), JSONRPCErrorError> { let import_id = Uuid::new_v4().to_string(); 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(¶ms.migration_items); let has_migration_items = !params.migration_items.is_empty(); let has_plugin_imports = params.migration_items.iter().any(|item| { @@ -228,8 +232,10 @@ impl ExternalAgentConfigRequestProcessor { ) }); let (pending_session_imports, session_validation_result) = - self.validate_pending_session_imports(¶ms); - let import_outcome = self.import_external_agent_config(params).await; + self.validate_pending_session_imports(¶ms, &migration_service); + let import_outcome = self + .import_external_agent_config(params, &migration_service) + .await; if needs_runtime_refresh { self.config_processor.handle_config_mutation().await; } @@ -272,11 +278,12 @@ impl ExternalAgentConfigRequestProcessor { } let session_importer = self.session_importer.clone(); - let plugin_processor = self.clone(); let outgoing = Arc::clone(&self.outgoing); let state_db = self.state_db.clone(); let analytics_events_client = self.analytics_events_client.clone(); 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(|| { CoreImportItemResult::new( CoreMigrationItemType::Sessions, @@ -291,7 +298,11 @@ impl ExternalAgentConfigRequestProcessor { let session_imports = async move { let session_import_result = session_import_result?; 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; send_import_progress(&session_progress_outgoing, &session_import_id, &item_result) .await; @@ -307,8 +318,11 @@ impl ExternalAgentConfigRequestProcessor { pending_plugin_import.description.clone(), pending_plugin_import.cwd.clone(), ); - match plugin_processor - .complete_pending_plugin_import(pending_plugin_import) + match plugin_migration_service + .import_plugins( + pending_plugin_import.cwd.as_deref(), + Some(pending_plugin_import.details), + ) .await { Ok(plugin_outcome) => { @@ -318,7 +332,7 @@ impl ExternalAgentConfigRequestProcessor { record_import_error( &mut item_result, "plugin_import", - error.message.clone(), + error.to_string(), /*source*/ None, ); } @@ -393,6 +407,7 @@ impl ExternalAgentConfigRequestProcessor { fn validate_pending_session_imports( &self, params: &ExternalAgentConfigImportParams, + migration_service: &ExternalAgentConfigService, ) -> (Vec, Option) { let sessions = params .migration_items @@ -422,33 +437,31 @@ impl ExternalAgentConfigRequestProcessor { let mut selected_session_paths = HashSet::new(); let mut selected_sessions = Vec::new(); for session in sessions { - let canonical_path = match self - .migration_service - .external_agent_session_source_path(&session.path) - { - Ok(Some(canonical_path)) => canonical_path, - Ok(None) => { - record_import_error( - &mut item_result, - "session_missing", - format!( - "external agent session was not detected for import: {}", - session.path.display() - ), - Some(session.path.display().to_string()), - ); - continue; - } - Err(err) => { - record_import_error( - &mut item_result, - "session_source_path", - err.to_string(), - Some(session.path.display().to_string()), - ); - continue; - } - }; + let canonical_path = + match migration_service.external_agent_session_source_path(&session.path) { + Ok(Some(canonical_path)) => canonical_path, + Ok(None) => { + record_import_error( + &mut item_result, + "session_missing", + format!( + "external agent session was not detected for import: {}", + session.path.display() + ), + Some(session.path.display().to_string()), + ); + continue; + } + Err(err) => { + record_import_error( + &mut item_result, + "session_source_path", + err.to_string(), + Some(session.path.display().to_string()), + ); + continue; + } + }; if selected_session_paths.insert(canonical_path) { selected_sessions.push(session); } @@ -459,8 +472,9 @@ impl ExternalAgentConfigRequestProcessor { async fn import_external_agent_config( &self, params: ExternalAgentConfigImportParams, + migration_service: &ExternalAgentConfigService, ) -> CoreImportOutcome { - self.migration_service + migration_service .import( params .migration_items @@ -558,19 +572,6 @@ impl ExternalAgentConfigRequestProcessor { ) .await } - - async fn complete_pending_plugin_import( - &self, - pending_plugin_import: PendingPluginImport, - ) -> Result { - 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( diff --git a/codex-rs/app-server/src/external_agent_migration/service.rs b/codex-rs/app-server/src/external_agent_migration/service.rs index 59872163816c..2986b5d9a488 100644 --- a/codex-rs/app-server/src/external_agent_migration/service.rs +++ b/codex-rs/app-server/src/external_agent_migration/service.rs @@ -1,5 +1,6 @@ mod source; mod source_cla; +mod source_cur; mod utils; 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::missing_subagent_names; use codex_external_agent_migration::sessions::ExternalAgentSessionMigration; +use codex_external_agent_migration::sessions::SessionMetadataMode; use codex_protocol::protocol::Product; use serde_json::Value as JsonValue; 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)] fn new_for_test(codex_home: PathBuf, external_agent_home: PathBuf) -> Self { 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 mut mcp_server_names = migrated_mcp_server_names(&migrated_mcp); if !is_empty_toml_table(&migrated_mcp) { @@ -747,6 +769,7 @@ impl ExternalAgentConfigService { external_agent_home: self.external_agent_home.as_path(), source_settings: source_settings.as_path(), source_root, + repo_root, settings: settings.as_ref(), configured_plugin_ids: &configured_plugin_ids, configured_marketplace_plugins: &configured_marketplace_plugins, @@ -823,7 +846,10 @@ impl ExternalAgentConfigService { fn effective_source_settings(&self, repo_root: Option<&Path>) -> io::Result> { 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( @@ -838,6 +864,7 @@ impl ExternalAgentConfigService { }; self.source.build_mcp_config( self.source_root(repo_root).as_path(), + self.source_config_dir(repo_root).as_path(), self.external_agent_home.as_path(), settings.as_ref(), ) @@ -969,6 +996,19 @@ impl ExternalAgentConfigService { if let Some(analytics_events_client) = self.analytics_events_client.clone() { plugins_manager.set_analytics_events_client(analytics_events_client); } + let configured_marketplace_paths = plugins_manager + .list_marketplaces_for_config( + &config.plugins_config_input(), + &[], + /*include_openai_curated*/ true, + ) + .map_err(|err| { + invalid_data_error(format!("failed to list configured marketplaces: {err}")) + })? + .marketplaces + .into_iter() + .map(|marketplace| (marketplace.name, marketplace.path)) + .collect::>(); let import_sources = self.marketplace_import_sources(cwd)?; for plugin_group in plugins { let marketplace_name = plugin_group.marketplace_name.clone(); @@ -977,64 +1017,71 @@ impl ExternalAgentConfigService { .iter() .map(|plugin_name| format!("{plugin_name}@{marketplace_name}")) .collect::>(); - let import_source = import_sources.get(&marketplace_name).cloned(); - let Some(import_source) = import_source else { - let message = format!( - "external agent plugin marketplace source was not found: {marketplace_name}" - ); - record_plugin_import_errors( - &mut outcome, - cwd, - &plugin_ids, - "plugin_import", - message, - ); - outcome.failed_marketplaces.push(marketplace_name); - outcome.failed_plugin_ids.extend(plugin_ids); - continue; - }; - let request = MarketplaceAddRequest { - source: import_source.source, - ref_name: import_source.ref_name, - sparse_paths: Vec::new(), - }; - let add_marketplace_outcome = - add_marketplace(self.codex_home.clone(), 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) => { + let marketplace_path = if let Some(marketplace_path) = + configured_marketplace_paths.get(&marketplace_name) + { + outcome + .succeeded_marketplaces + .push(marketplace_name.clone()); + marketplace_path.clone() + } else { + let Some(import_source) = import_sources.get(&marketplace_name).cloned() else { + let message = format!( + "external agent plugin marketplace source was not found: {marketplace_name}" + ); record_plugin_import_errors( &mut outcome, cwd, &plugin_ids, "plugin_import", - err.to_string(), + message, ); outcome.failed_marketplaces.push(marketplace_name); outcome.failed_plugin_ids.extend(plugin_ids); continue; + }; + let request = MarketplaceAddRequest { + source: import_source.source, + ref_name: import_source.ref_name, + sparse_paths: Vec::new(), + }; + match add_marketplace(self.codex_home.clone(), requirements.clone(), request).await + { + Ok(add_marketplace_outcome) => { + let Some(marketplace_path) = find_marketplace_manifest_path( + add_marketplace_outcome.installed_root.as_path(), + ) else { + let message = format!( + "plugin marketplace manifest was not found after install: {marketplace_name}" + ); + record_plugin_import_errors( + &mut outcome, + cwd, + &plugin_ids, + "plugin_import", + message, + ); + outcome.failed_marketplaces.push(marketplace_name); + outcome.failed_plugin_ids.extend(plugin_ids); + continue; + }; + outcome + .succeeded_marketplaces + .push(marketplace_name.clone()); + marketplace_path + } + Err(err) => { + record_plugin_import_errors( + &mut outcome, + cwd, + &plugin_ids, + "plugin_import", + err.to_string(), + ); + outcome.failed_marketplaces.push(marketplace_name); + outcome.failed_plugin_ids.extend(plugin_ids); + continue; + } } }; let install_config = match ConfigBuilder::default() diff --git a/codex-rs/app-server/src/external_agent_migration/service/source.rs b/codex-rs/app-server/src/external_agent_migration/service/source.rs index a0041a20ffca..a4ec71fd46f3 100644 --- a/codex-rs/app-server/src/external_agent_migration/service/source.rs +++ b/codex-rs/app-server/src/external_agent_migration/service/source.rs @@ -1,6 +1,8 @@ use codex_external_agent_migration::RewriteProfile; use codex_external_agent_migration::sessions::ExternalAgentSessionMigration; +use codex_external_agent_migration::sessions::SessionMetadataMode; use codex_external_agent_migration::sessions::detect_recent_cla_sessions; +use codex_external_agent_migration::sessions::detect_recent_cur_sessions; use serde_json::Value as JsonValue; use std::collections::BTreeMap; use std::collections::HashSet; @@ -11,6 +13,7 @@ use toml::Value as TomlValue; use super::MigrationDetails; use super::source_cla; +use super::source_cur; #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct InstructionSourceGroup { @@ -33,6 +36,7 @@ pub(super) struct PluginDetectionContext<'a> { pub(super) external_agent_home: &'a Path, pub(super) source_settings: &'a Path, pub(super) source_root: &'a Path, + pub(super) repo_root: Option<&'a Path>, pub(super) settings: Option<&'a JsonValue>, pub(super) configured_plugin_ids: &'a HashSet, pub(super) configured_marketplace_plugins: &'a BTreeMap>, @@ -42,6 +46,7 @@ pub(super) struct PluginDetectionContext<'a> { pub(super) enum ExternalAgentSource { #[default] Cla, + Cur, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -52,9 +57,20 @@ pub(super) enum SourceFeature { } 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 { match self { Self::Cla => source_cla::CONFIG_DIR, + Self::Cur => source_cur::CONFIG_DIR, } } @@ -64,21 +80,29 @@ impl ExternalAgentSource { Self::Cla, SourceFeature::Config | SourceFeature::Plugins | SourceFeature::Sessions, ) => true, + ( + Self::Cur, + SourceFeature::Config | SourceFeature::Plugins | SourceFeature::Sessions, + ) => true, } } pub(super) fn settings_file_name(self, project_scope: bool) -> &'static str { match (self, project_scope) { (Self::Cla, _) => "settings.json", + (Self::Cur, false) => source_cur::HOME_CONFIG_FILE, + (Self::Cur, true) => source_cur::PROJECT_CONFIG_FILE, } } pub(super) fn effective_settings( self, + source_config_dir: &Path, source_settings: &Path, ) -> io::Result> { match self { 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> { match self { 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 { match self { Self::Cla => source_cla::can_detect_plugins(settings), + Self::Cur => true, } } @@ -104,12 +131,21 @@ impl ExternalAgentSource { ) -> io::Result> { match self { 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 { match self { Self::Cla => source_cla::connector_metadata_roots(external_agent_home), + Self::Cur => Vec::new(), } } @@ -130,6 +166,7 @@ impl ExternalAgentSource { ) }) .unwrap_or_default()), + Self::Cur => source_cur::marketplace_import_sources(external_agent_home), } } @@ -140,23 +177,31 @@ impl ExternalAgentSource { ) { match self { Self::Cla => source_cla::append_config(root, settings), + Self::Cur => source_cur::append_config(root, settings), } } pub(super) fn build_mcp_config( self, source_root: &Path, + source_config_dir: &Path, external_agent_home: &Path, settings: Option<&JsonValue>, ) -> io::Result { match self { Self::Cla => source_cla::build_mcp_config(source_root, external_agent_home, settings), + Self::Cur => source_cur::build_mcp_config(source_config_dir), } } - 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 { Self::Cla => source_root, + Self::Cur => source_config_dir.join("mcp.json"), } } @@ -166,6 +211,7 @@ impl ExternalAgentSource { ) -> io::Result> { match self { 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> { match self { 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 { match self { Self::Cla => source_cla::read_instruction_source(path), + Self::Cur => source_cur::read_instruction_source(path), } } @@ -191,6 +239,7 @@ impl ExternalAgentSource { ) -> io::Result> { match self { 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 { match self { 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> { match self { 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> { match self { 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> { match self { Self::Cla => source_cla::source_hook_event_names(source_dir, target_hooks), + Self::Cur => source_cur::source_hook_event_names(source_dir, target_hooks), } } pub(super) fn import_hooks(self, source_dir: &Path, target_hooks: &Path) -> io::Result { match self { 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 { match self { Self::Cla => source_cla::REWRITE_PROFILE, + Self::Cur => source_cur::REWRITE_PROFILE, } } } diff --git a/codex-rs/app-server/src/external_agent_migration/service/source_cur.rs b/codex-rs/app-server/src/external_agent_migration/service/source_cur.rs new file mode 100644 index 000000000000..08a10b977e21 --- /dev/null +++ b/codex-rs/app-server/src/external_agent_migration/service/source_cur.rs @@ -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, +} + +pub(super) fn build_mcp_config(source_dir: &Path) -> io::Result { + build_mcp_config_from_json_file(&source_dir.join("mcp.json")) +} + +pub(super) fn repo_instruction_source_groups( + repo_root: &Path, +) -> io::Result> { + 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 { + fs::read_to_string(path) +} + +pub(super) fn effective_settings( + source_dir: &Path, + source_settings: &Path, +) -> io::Result> { + 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, + settings: &serde_json::Map, +) { + 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::>(); + 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> { + 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::>(); + 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> { + 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> { + 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 { + 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> { + 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> { + 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> { + 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 { + 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> { + 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::>(); + 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::>(); + 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; diff --git a/codex-rs/app-server/src/external_agent_migration/service/source_cur_tests.rs b/codex-rs/app-server/src/external_agent_migration/service/source_cur_tests.rs new file mode 100644 index 000000000000..3f9e15edc7c8 --- /dev/null +++ b/codex-rs/app-server/src/external_agent_migration/service/source_cur_tests.rs @@ -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" + ); +} diff --git a/codex-rs/app-server/src/external_agent_migration/service_tests.rs b/codex-rs/app-server/src/external_agent_migration/service_tests.rs index 2765e7dda221..5ba7c890d369 100644 --- a/codex-rs/app-server/src/external_agent_migration/service_tests.rs +++ b/codex-rs/app-server/src/external_agent_migration/service_tests.rs @@ -2545,6 +2545,121 @@ async fn import_plugins_supports_external_agent_plugin_marketplace_layout() { assert!(config.contains("enabled = true")); } +#[tokio::test] +async fn import_plugins_reuses_configured_marketplace_with_different_source() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + let configured_marketplace_root = external_agent_home.join("configured-marketplace"); + let source_marketplace_root = external_agent_home.join("source-marketplace"); + let configured_plugin_root = configured_marketplace_root.join("plugins/cloudflare"); + let source_plugin_root = source_marketplace_root.join("plugins/cloudflare"); + fs::create_dir_all(configured_marketplace_root.join(".agents/plugins")) + .expect("create configured marketplace manifest dir"); + fs::create_dir_all(configured_plugin_root.join(".codex-plugin")) + .expect("create configured plugin manifest dir"); + fs::create_dir_all(source_marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)) + .expect("create source marketplace manifest dir"); + fs::create_dir_all(source_plugin_root.join(".codex-plugin")) + .expect("create source plugin manifest dir"); + fs::create_dir_all(&codex_home).expect("create codex home"); + + fs::write( + external_agent_home.join("settings.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "enabledPlugins": { + "cloudflare@my-plugins": true + }, + "extraKnownMarketplaces": { + "my-plugins": { + "source": "local", + "path": source_marketplace_root + } + } + })) + .expect("serialize settings"), + ) + .expect("write settings"); + fs::write( + codex_home.join("config.toml"), + format!( + r#"[marketplaces.my-plugins] +source_type = "local" +source = {configured_marketplace_root:?} +"# + ), + ) + .expect("write Codex config"); + fs::write( + configured_marketplace_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "my-plugins", + "plugins": [{ + "name": "cloudflare", + "source": {"source": "local", "path": "./plugins/cloudflare"} + }] + }"#, + ) + .expect("write configured marketplace manifest"); + fs::write( + source_marketplace_root + .join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR) + .join("marketplace.json"), + r#"{ + "name": "my-plugins", + "plugins": [{"name": "cloudflare", "source": "./plugins/cloudflare"}] + }"#, + ) + .expect("write source marketplace manifest"); + fs::write( + configured_plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"cloudflare","version":"0.1.0"}"#, + ) + .expect("write configured plugin manifest"); + fs::write( + source_plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"cloudflare","version":"0.2.0"}"#, + ) + .expect("write source plugin manifest"); + + let outcome = service_for_paths(external_agent_home, codex_home.clone()) + .import_plugins( + /*cwd*/ None, + Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "my-plugins".to_string(), + plugin_names: vec!["cloudflare".to_string()], + }], + ..Default::default() + }), + ) + .await + .expect("import plugins"); + + assert_eq!( + outcome, + PluginImportOutcome { + succeeded_marketplaces: vec!["my-plugins".to_string()], + succeeded_plugin_ids: vec!["cloudflare@my-plugins".to_string()], + failed_marketplaces: Vec::new(), + failed_plugin_ids: Vec::new(), + raw_errors: Vec::new(), + } + ); + let config: TomlValue = + toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config")) + .expect("parse config"); + let expected: TomlValue = toml::from_str(&format!( + r#"[marketplaces.my-plugins] +source_type = "local" +source = {configured_marketplace_root:?} + +[plugins."cloudflare@my-plugins"] +enabled = true +"# + )) + .expect("parse expected config"); + assert_eq!(config, expected); +} + #[tokio::test] async fn detect_home_supports_relative_external_agent_plugin_marketplace_path() { let (_root, external_agent_home, codex_home) = fixture_paths(); diff --git a/codex-rs/app-server/src/external_agent_migration/session_importer.rs b/codex-rs/app-server/src/external_agent_migration/session_importer.rs index 2d520ad005f3..fa02009bc6e0 100644 --- a/codex-rs/app-server/src/external_agent_migration/session_importer.rs +++ b/codex-rs/app-server/src/external_agent_migration/session_importer.rs @@ -10,8 +10,9 @@ use codex_external_agent_migration::sessions::ExternalAgentSessionMigration; use codex_external_agent_migration::sessions::ImportedExternalAgentSession; use codex_external_agent_migration::sessions::ImportedSessionConnectorAttribution; 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::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_models_manager::manager::RefreshStrategy; use codex_protocol::ThreadId; @@ -75,6 +76,7 @@ impl ExternalAgentSessionImporter { &self, sessions: Vec, mut item_result: ExternalAgentConfigImportItemResult, + metadata_mode: SessionMetadataMode, ) -> ExternalAgentConfigImportItemResult { if sessions.is_empty() { return item_result; @@ -91,7 +93,11 @@ impl ExternalAgentSessionImporter { let import_results = futures::stream::iter(sessions) .map(|session| { 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); futures::pin_mut!(import_results); @@ -167,16 +173,17 @@ impl ExternalAgentSessionImporter { async fn import_requested_session( &self, session: ExternalAgentSessionMigration, + metadata_mode: SessionMetadataMode, ) -> Result, SessionImportFailure> { let source_path = session.path.clone(); - let Some(pending_import) = - self.prepare_session_import(session) - .await - .map_err(|message| SessionImportFailure { - source_path: source_path.clone(), - message, - stage: "session_prepare", - })? + let Some(pending_import) = self + .prepare_session_import(session, metadata_mode) + .await + .map_err(|message| SessionImportFailure { + source_path: source_path.clone(), + message, + stage: "session_prepare", + })? else { return Ok(None); }; @@ -212,12 +219,15 @@ impl ExternalAgentSessionImporter { async fn prepare_session_import( &self, session: ExternalAgentSessionMigration, + metadata_mode: SessionMetadataMode, ) -> Result, String> { let codex_home = self.codex_home.clone(); - tokio::task::spawn_blocking(move || prepare_validated_session_import(&codex_home, session)) - .await - .map_err(|err| format!("external agent session preparation task failed: {err}"))? - .map_err(|err| format!("failed to prepare external agent session: {err}")) + tokio::task::spawn_blocking(move || { + prepare_validated_session_import_with_metadata_mode(&codex_home, session, metadata_mode) + }) + .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( diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 1a63adc09c74..2a3dad094563 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -43,6 +43,7 @@ use tokio::time::timeout; use super::analytics::wait_for_analytics_event; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); +const SECONDARY_MIGRATION_SOURCE: &str = concat!("cur", "sor"); fn external_agent_home(codex_home: &Path) -> PathBuf { 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 { assert!(!response.import_id.is_empty()); response.import_id } #[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<()> { 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"), "project instructions")?; - let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .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??; let mut responses = Vec::new(); - for source in [None, Some("claude-code"), Some("unknown-source")] { - let mut params = serde_json::json!({ "includeHome": true }); - if let Some(source) = source { - params["source"] = serde_json::json!(source); - } + for params in [ + serde_json::json!({ "includeHome": true }), + serde_json::json!({ + "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 .send_raw_request("externalAgentConfig/detect", Some(params)) .await?; @@ -108,7 +122,411 @@ async fn external_agent_config_detect_accepts_source_selection_and_defaults_unkn ExternalAgentConfigMigrationItemType::AgentsMd ); 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::(); + #[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": "first request" + }] + } + }) + .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(()) } diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index 6475d3787600..b3ef42c8e920 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -21,6 +21,7 @@ const MARKETPLACE_MANIFEST_RELATIVE_PATHS: &[&str] = &[ ".agents/plugins/marketplace.json", ".agents/plugins/api_marketplace.json", ".claude-plugin/marketplace.json", + ".cursor-plugin/marketplace.json", ]; #[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. - 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 { path: marketplace_path.to_path_buf(), message: "local plugin source path must start with `./`".to_string(), diff --git a/codex-rs/core-plugins/src/marketplace_tests.rs b/codex-rs/core-plugins/src/marketplace_tests.rs index d30e9ad1dacd..d8b466d48ed5 100644 --- a/codex-rs/core-plugins/src/marketplace_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_tests.rs @@ -6,6 +6,8 @@ use tempfile::tempdir; const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.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 { let marketplace_path = repo_root.join(ALTERNATE_MARKETPLACE_RELATIVE_PATH); 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(); } +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 { MarketplacePluginManifestFallback { 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] fn find_marketplace_plugin_supports_git_subdir_sources() { let tmp = tempdir().unwrap(); diff --git a/codex-rs/external-agent-migration/src/hooks_cur.rs b/codex-rs/external-agent-migration/src/hooks_cur.rs new file mode 100644 index 000000000000..ef7564106a93 --- /dev/null +++ b/codex-rs/external-agent-migration/src/hooks_cur.rs @@ -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> { + 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 { + 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> { + 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; diff --git a/codex-rs/external-agent-migration/src/hooks_cur_tests.rs b/codex-rs/external-agent-migration/src/hooks_cur_tests.rs new file mode 100644 index 000000000000..ff5afc6dd0cf --- /dev/null +++ b/codex-rs/external-agent-migration/src/hooks_cur_tests.rs @@ -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" + ); +} diff --git a/codex-rs/external-agent-migration/src/lib.rs b/codex-rs/external-agent-migration/src/lib.rs index 52506a9a8f27..712545e0664e 100644 --- a/codex-rs/external-agent-migration/src/lib.rs +++ b/codex-rs/external-agent-migration/src/lib.rs @@ -1,6 +1,7 @@ //! Migration helpers for importing external-agent configuration into Codex. mod hooks_cla; +mod hooks_cur; pub mod sessions; 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::hooks_migration_description_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 EXTERNAL_AGENT_MCP_CONFIG_FILE: &str = ".mcp.json"; diff --git a/codex-rs/external-agent-migration/src/sessions/detect_cur.rs b/codex-rs/external-agent-migration/src/sessions/detect_cur.rs new file mode 100644 index 000000000000..9a5fbdd504b4 --- /dev/null +++ b/codex-rs/external-agent-migration/src/sessions/detect_cur.rs @@ -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> { + 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 { + 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 { + let encoded = project_storage.file_name()?.to_str()?; + decode_cur_project_path(encoded) +} + +#[cfg(not(windows))] +fn decode_cur_project_path(encoded: &str) -> Option { + 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 { + 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, +) { + 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) -> Option { + (matches.len() == 1).then(|| matches.swap_remove(0)) +} + +#[cfg(test)] +#[path = "detect_cur_tests.rs"] +mod tests; diff --git a/codex-rs/external-agent-migration/src/sessions/detect_cur_tests.rs b/codex-rs/external-agent-migration/src/sessions/detect_cur_tests.rs new file mode 100644 index 000000000000..d196e3f62676 --- /dev/null +++ b/codex-rs/external-agent-migration/src/sessions/detect_cur_tests.rs @@ -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": "first request", + }], + }, + }) + .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!("{first_request}"), + }], + }, + }) + .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) +} diff --git a/codex-rs/external-agent-migration/src/sessions/export.rs b/codex-rs/external-agent-migration/src/sessions/export.rs index fc3335c781ea..c35243f6c34e 100644 --- a/codex-rs/external-agent-migration/src/sessions/export.rs +++ b/codex-rs/external-agent-migration/src/sessions/export.rs @@ -1,7 +1,7 @@ use super::ConversationMessage; use super::ImportedExternalAgentSession; use super::MessageRole; -use super::records::read_session_import; +use super::records::read_session_import_with_cwd; use super::summarize_for_label; use super::title::IMPORTED_SESSION_FALLBACK_TITLE; use super::title::SessionTitleCandidates; @@ -26,14 +26,17 @@ const EXTERNAL_SESSION_IMPORTED_MARKER: &str = ""; #[cfg(test)] fn load_session_for_import(path: &Path) -> io::Result> { - Ok(load_session_for_import_with_content_sha256(path)? - .map(|(session, _content_sha256, _attributed_mcp_server_ids)| session)) + Ok( + 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( path: &Path, + fallback_cwd: Option<&Path>, ) -> io::Result)>> { - let parsed = read_session_import(path)?; + let parsed = read_session_import_with_cwd(path, fallback_cwd)?; let Some(cwd) = parsed.cwd else { return Ok(None); }; diff --git a/codex-rs/external-agent-migration/src/sessions/mod.rs b/codex-rs/external-agent-migration/src/sessions/mod.rs index 0369f4961657..1094c891293c 100644 --- a/codex-rs/external-agent-migration/src/sessions/mod.rs +++ b/codex-rs/external-agent-migration/src/sessions/mod.rs @@ -2,6 +2,7 @@ mod connectors_cla; mod detect_cla; +mod detect_cur; mod export; mod ledger; mod records; @@ -16,6 +17,7 @@ use std::path::PathBuf; pub use connectors_cla::ImportedSessionConnectorAttribution; pub use connectors_cla::detect_imported_cla_session_connectors; pub use detect_cla::detect_recent_cla_sessions; +pub use detect_cur::detect_recent_cur_sessions; use export::load_session_for_import_with_content_sha256; pub use ledger::CompletedExternalAgentSessionImport; pub use ledger::ImportedConnectorCandidate; @@ -33,6 +35,15 @@ fn normalized_connector_display_name(name: Option<&str>) -> Option { .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)] pub struct ExternalAgentSessionMigration { pub path: PathBuf, @@ -59,18 +70,38 @@ pub struct PendingSessionImport { pub fn prepare_validated_session_import( codex_home: &Path, session: ExternalAgentSessionMigration, +) -> io::Result> { + 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> { let has_been_imported = has_current_session_been_imported(codex_home, &session.path)?; if has_been_imported { 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> { +fn load_importable_session( + path: &Path, + fallback_cwd: &Path, + metadata_mode: SessionMetadataMode, +) -> io::Result> { 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)) = - load_session_for_import_with_content_sha256(&source_path)? + load_session_for_import_with_content_sha256(&source_path, fallback_cwd)? else { 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 { ExternalAgentSessionMigration { path: path.to_path_buf(), diff --git a/codex-rs/external-agent-migration/src/sessions/records.rs b/codex-rs/external-agent-migration/src/sessions/records.rs index f19bfd11ab7e..ef38653b4d53 100644 --- a/codex-rs/external-agent-migration/src/sessions/records.rs +++ b/codex-rs/external-agent-migration/src/sessions/records.rs @@ -36,7 +36,15 @@ pub(super) struct ParsedSessionImport { } pub fn summarize_session(path: &Path) -> io::Result> { + summarize_session_with_cwd(path, /*fallback_cwd*/ None) +} + +pub(crate) fn summarize_session_with_cwd( + path: &Path, + fallback_cwd: Option<&Path>, +) -> io::Result> { let file = File::open(path)?; + let fallback_timestamp = fallback_cwd.and_then(|_| file_modified_at_seconds(&file)); let reader = BufReader::new(file); let mut cwd = None; let mut custom_title = None; @@ -67,7 +75,8 @@ pub fn summarize_session(path: &Path) -> io::Result> { if let Some(title) = ai_title_from_record(&record) { 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; }; saw_message = true; @@ -83,7 +92,7 @@ pub fn summarize_session(path: &Path) -> io::Result> { } } - let Some(cwd) = cwd else { + let Some(cwd) = cwd.or_else(|| fallback_cwd.map(Path::to_path_buf)) else { return Ok(None); }; if !saw_message { @@ -109,8 +118,12 @@ pub fn summarize_session(path: &Path) -> io::Result> { })) } -pub(super) fn read_session_import(path: &Path) -> io::Result { +pub(super) fn read_session_import_with_cwd( + path: &Path, + fallback_cwd: Option<&Path>, +) -> io::Result { 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 cwd = None; let mut custom_title = None; @@ -152,12 +165,14 @@ pub(super) fn read_session_import(path: &Path) -> io::Result(record: &'a JsonValue, record_type: &str, field: &str) .filter(|title| !title.is_empty()) } -fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option { - let record_type = record.get("type")?.as_str()?; - if record_type != "assistant" && record_type != "user" { +fn conversation_message_from_owned_record( + record: &mut JsonValue, + fallback_timestamp: Option, +) -> Option { + 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; } 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 { @@ -211,17 +240,46 @@ fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option 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 { - role: if is_assistant || extracted.only_tool_result { - MessageRole::Assistant - } else { - MessageRole::User - }, - text: extracted.text, + role, + text, timestamp, }) } +fn unwrap_user_query(text: String) -> String { + let trimmed = text.trim(); + let Some(inner) = trimmed + .strip_prefix("") + .and_then(|inner| inner.strip_suffix("")) + .map(str::trim) + .filter(|inner| !inner.is_empty()) + else { + return text; + }; + inner.to_string() +} + +fn file_modified_at_seconds(file: &File) -> Option { + file.metadata() + .ok()? + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) +} + struct ExtractedMessage { text: String, only_tool_result: bool, @@ -379,7 +437,7 @@ mod tests { "type": "user", "cwd": root.path(), "timestamp": "2026-06-03T12:00:00Z", - "message": { "content": "first request" }, + "message": { "content": "\nfirst request\n" }, }) .to_string(), "not json".to_string(), @@ -397,7 +455,8 @@ mod tests { .join("\n"); 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.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] fn converts_tool_use_blocks_to_bounded_external_agent_tags() { let block = serde_json::json!({ diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 3de1088fb622..f7a37e1f60f9 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -134,8 +134,7 @@ use uuid::Uuid; const JSONRPC_INVALID_REQUEST: i64 = -32600; const JSONRPC_METHOD_NOT_FOUND: i64 = -32601; -pub(crate) const EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE: &str = - "A previous Claude Code import is still running. Wait for it to finish before importing again."; +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."; const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -411,12 +410,13 @@ impl AppServerSession { self.client .request_typed(ClientRequest::ExternalAgentConfigDetect { request_id, params }) .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( &mut self, migration_items: Vec, + migration_source: String, ) -> Result<()> { // Mark the import active before sending the request so a fast completion notification // cannot arrive before the TUI records it. @@ -433,11 +433,12 @@ impl AppServerSession { request_id, params: ExternalAgentConfigImportParams { migration_items, - source: Some("claude-code".to_string()), + source: Some(migration_source.clone()), + migration_source: Some(migration_source), }, }) .await - .wrap_err("externalAgentConfig/import failed during Claude Code import"); + .wrap_err("externalAgentConfig/import failed during external agent import"); match response { Ok(_) => Ok(()), Err(err) => { diff --git a/codex-rs/tui/src/external_agent_config_migration.rs b/codex-rs/tui/src/external_agent_config_migration.rs index 995c97f51707..6f8e831e48f8 100644 --- a/codex-rs/tui/src/external_agent_config_migration.rs +++ b/codex-rs/tui/src/external_agent_config_migration.rs @@ -868,6 +868,31 @@ mod tests { 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] fn customize_action_snapshot() { let items = sample_items(); diff --git a/codex-rs/tui/src/external_agent_config_migration/render.rs b/codex-rs/tui/src/external_agent_config_migration/render.rs index e0e4926a9e24..508a3c0aa5ac 100644 --- a/codex-rs/tui/src/external_agent_config_migration/render.rs +++ b/codex-rs/tui/src/external_agent_config_migration/render.rs @@ -78,15 +78,14 @@ impl WidgetRef for &ExternalAgentConfigMigrationScreen { let error_height = u16::from(self.error.is_some()); let intro_lines = match self.view { 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("Your existing Claude Code setup will not be changed."), - Line::from("Standard Claude Chat data cannot be imported."), + Line::from("Your existing setup will not be changed."), ], 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("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; @@ -119,7 +118,7 @@ impl WidgetRef for &ExternalAgentConfigMigrationScreen { .areas(inner_area); let title = match self.view { - MigrationView::Summary => "Import from Claude Code", + MigrationView::Summary => "Import setup", MigrationView::Customize => "Choose what to import", }; let heading = Line::from(vec!["> ".into(), title.bold()]); diff --git a/codex-rs/tui/src/external_agent_config_migration_flow.rs b/codex-rs/tui/src/external_agent_config_migration_flow.rs index b292a47f9d4f..5749743ef82a 100644 --- a/codex-rs/tui/src/external_agent_config_migration_flow.rs +++ b/codex-rs/tui/src/external_agent_config_migration_flow.rs @@ -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_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_source::ExternalAgentConfigMigrationSource; +use crate::external_agent_config_migration_source::run_external_agent_config_source_prompt; use crate::legacy_core::config::Config; use crate::tui; use codex_app_server_protocol::ExternalAgentConfigDetectParams; @@ -14,9 +16,9 @@ use ratatui::prelude::Stylize as _; use ratatui::text::Line; pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_NO_ITEMS_MESSAGE: &str = - "No Claude Code 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_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."; + "No compatible setup was found to 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 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 { Started(Vec>), @@ -24,6 +26,57 @@ pub(crate) enum ExternalAgentConfigMigrationFlowOutcome { Cancelled, } +struct DetectedExternalAgentConfigSource { + source: ExternalAgentConfigMigrationSource, + items: Vec, +} + +#[derive(Default)] +struct ExternalAgentConfigDetection { + sources: Vec, + errors: Vec, +} + +enum ExternalAgentConfigDetectionOutcome { + Sources(Vec), + NoItems, + Failed(String), +} + +impl ExternalAgentConfigDetection { + fn record_items( + &mut self, + source: ExternalAgentConfigMigrationSource, + items: Vec, + ) { + 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( selected_items: &[ExternalAgentConfigMigrationItem], remaining_item_count: usize, @@ -89,7 +142,7 @@ fn external_agent_config_migration_started_lines( let mut lines = vec![ vec![ "• ".dim(), - "Claude Code import started.".cyan(), + "Import started.".cyan(), " You can keep working while it finishes.".into(), ] .into(), @@ -145,7 +198,7 @@ pub(crate) fn external_agent_config_migration_finished_lines( let mut lines = vec![ vec![ "• ".dim(), - "Claude Code import finished: ".into(), + "Import finished: ".into(), format!("{imported_count} imported").green(), ", ".into(), failed_count, @@ -212,28 +265,59 @@ pub(crate) async fn handle_external_agent_config_migration_prompt( } let cwd = config.cwd.to_path_buf(); - let detected_items = match app_server - .external_agent_config_detect(ExternalAgentConfigDetectParams { - include_home: true, - cwds: Some(vec![cwd.clone()]), - source: Some("claude-code".to_string()), - }) - .await - { - Ok(response) => response.items, - Err(err) => { - tracing::warn!( - error = %err, - cwd = %cwd.display(), - "failed to detect external agent config migrations" - ); - return Err(format!("Could not check for Claude Code setup: {err}")); + let mut detection = ExternalAgentConfigDetection::default(); + for source in ExternalAgentConfigMigrationSource::ALL { + let response = match app_server + .external_agent_config_detect(ExternalAgentConfigDetectParams { + include_home: true, + cwds: Some(vec![cwd.clone()]), + source: None, + migration_source: Some(source.migration_source().to_string()), + }) + .await + { + Ok(response) => response, + Err(err) => { + tracing::warn!( + error = %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() { - return Ok(ExternalAgentConfigMigrationFlowOutcome::NoItems); - } + let selected_source = if detected_sources.len() == 1 { + detected_sources[0].source + } else { + let sources = detected_sources + .iter() + .map(|detected| detected.source) + .collect::>(); + 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 error: Option = None; @@ -249,7 +333,13 @@ pub(crate) async fn handle_external_agent_config_migration_prompt( { ExternalAgentConfigMigrationOutcome::Proceed(items) => { 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(()) => { let remaining_item_count = detected_items.len().saturating_sub(selected_items.len()); diff --git a/codex-rs/tui/src/external_agent_config_migration_flow_tests.rs b/codex-rs/tui/src/external_agent_config_migration_flow_tests.rs index 14aea2f4751d..1a9eea9d7b6c 100644 --- a/codex-rs/tui/src/external_agent_config_migration_flow_tests.rs +++ b/codex-rs/tui/src/external_agent_config_migration_flow_tests.rs @@ -12,6 +12,15 @@ use pretty_assertions::assert_eq; use ratatui::text::Line; 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 { vec![ ExternalAgentConfigMigrationItem { @@ -174,7 +183,7 @@ fn external_agent_config_migration_status_lines_use_semantic_colors() { vec![ Line::from(vec![ "• ".dim(), - "Claude Code import started.".cyan(), + "Import started.".cyan(), " You can keep working while it finishes.".into(), ]), Line::from(vec![ @@ -228,7 +237,7 @@ fn external_agent_config_migration_status_lines_use_semantic_colors() { vec![ Line::from(vec![ "• ".dim(), - "Claude Code import finished: ".into(), + "Import finished: ".into(), "2 imported".green(), ", ".into(), "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" + ); +} diff --git a/codex-rs/tui/src/external_agent_config_migration_model.rs b/codex-rs/tui/src/external_agent_config_migration_model.rs index 87d3310b3619..42415bb02aa9 100644 --- a/codex-rs/tui/src/external_agent_config_migration_model.rs +++ b/codex-rs/tui/src/external_agent_config_migration_model.rs @@ -79,8 +79,8 @@ pub(crate) fn external_agent_config_migration_item_label( item: &ExternalAgentConfigMigrationItem, ) -> &'static str { match item.item_type { - ExternalAgentConfigMigrationItemType::AgentsMd => "Instructions (CLAUDE.md -> AGENTS.md)", - ExternalAgentConfigMigrationItemType::Config => "Settings (settings.json -> config.toml)", + ExternalAgentConfigMigrationItemType::AgentsMd => "Instructions", + ExternalAgentConfigMigrationItemType::Config => "Settings", ExternalAgentConfigMigrationItemType::Skills => "Skills", ExternalAgentConfigMigrationItemType::Plugins => "Plugins", ExternalAgentConfigMigrationItemType::McpServerConfig => "MCP servers", diff --git a/codex-rs/tui/src/external_agent_config_migration_source.rs b/codex-rs/tui/src/external_agent_config_migration_source.rs new file mode 100644 index 000000000000..463313ca6602 --- /dev/null +++ b/codex-rs/tui/src/external_agent_config_migration_source.rs @@ -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 { + 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, + highlighted_idx: usize, + selection: Option, + 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 { + 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; diff --git a/codex-rs/tui/src/external_agent_config_migration_source_tests.rs b/codex-rs/tui/src/external_agent_config_migration_source_tests.rs new file mode 100644 index 000000000000..ea0264e20f09 --- /dev/null +++ b/codex-rs/tui/src/external_agent_config_migration_source_tests.rs @@ -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); +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index a26c36f5f01f..17492acdc21c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -116,6 +116,7 @@ mod exec_command; mod external_agent_config_migration; mod external_agent_config_migration_flow; mod external_agent_config_migration_model; +mod external_agent_config_migration_source; mod external_editor; mod file_search; mod frames; diff --git a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize.snap b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize.snap index 880933428116..a71e71ba5f99 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize.snap @@ -4,11 +4,11 @@ expression: rendered --- > Choose what to import - Choose the Claude Code items to import. + Choose items to import. 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 - › [x] Settings (settings.json -> config.toml) + › [x] Settings Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf… [x] Recent chat sessions Import recent Claude Code sessions @@ -21,7 +21,7 @@ expression: rendered • team-marketplace: asana • debug: sample • +1 more marketplaces - [x] Instructions (CLAUDE.md -> AGENTS.md) + [x] Instructions Import CLAUDE.md to AGENTS.md Selected 4 of 4 items. diff --git a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_action.snap b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_action.snap index 70132d75ae87..a59e82b27415 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_action.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_action.snap @@ -4,11 +4,11 @@ expression: rendered --- > Choose what to import - Choose the Claude Code items to import. + Choose items to import. 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 - [x] Settings (settings.json -> config.toml) + [x] Settings Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf… [x] Recent chat sessions Import recent Claude Code sessions @@ -21,7 +21,7 @@ expression: rendered • team-marketplace: asana • debug: sample • +1 more marketplaces - [x] Instructions (CLAUDE.md -> AGENTS.md) + [x] Instructions Import CLAUDE.md to AGENTS.md Selected 4 of 4 items. diff --git a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_action_windows.snap b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_action_windows.snap index 48dfe2e22b04..04651bdf8826 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_action_windows.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_action_windows.snap @@ -4,11 +4,11 @@ expression: rendered --- > Choose what to import - Choose the Claude Code items to import. + Choose items to import. 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 - [x] Settings (settings.json -> config.toml) + [x] Settings Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf… [x] Recent chat sessions Import recent Claude Code sessions @@ -21,7 +21,7 @@ expression: rendered • team-marketplace: asana • debug: sample • +1 more marketplaces - [x] Instructions (CLAUDE.md -> AGENTS.md) + [x] Instructions Import CLAUDE.md to AGENTS.md Selected 4 of 4 items. diff --git a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_windows.snap b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_windows.snap index db6c14a0ca3e..d951f691b87b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_windows.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_windows.snap @@ -4,11 +4,11 @@ expression: rendered --- > Choose what to import - Choose the Claude Code items to import. + Choose items to import. 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 - › [x] Settings (settings.json -> config.toml) + › [x] Settings Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf… [x] Recent chat sessions Import recent Claude Code sessions @@ -21,7 +21,7 @@ expression: rendered • team-marketplace: asana • debug: sample • +1 more marketplaces - [x] Instructions (CLAUDE.md -> AGENTS.md) + [x] Instructions Import CLAUDE.md to AGENTS.md Selected 4 of 4 items. diff --git a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap index 165473c5a779..4d9ded3ea6e3 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap @@ -3,11 +3,10 @@ source: tui/src/external_agent_config_migration.rs expression: rendered --- - > Import from Claude Code - Bring over your setup, current project, and recent chats. + > Import setup + Bring over supported setup from another coding agent. Codex may add files to your current project folder. - Your existing Claude Code setup will not be changed. - Standard Claude Chat data cannot be imported. + Your existing setup will not be changed. [x] Tools & setup Settings, instructions, integrations, agents, commands, and skills Importing: Settings 1 diff --git a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt_windows.snap b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt_windows.snap index 165473c5a779..4d9ded3ea6e3 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt_windows.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt_windows.snap @@ -3,11 +3,10 @@ source: tui/src/external_agent_config_migration.rs expression: rendered --- - > Import from Claude Code - Bring over your setup, current project, and recent chats. + > Import setup + Bring over supported setup from another coding agent. Codex may add files to your current project folder. - Your existing Claude Code setup will not be changed. - Standard Claude Chat data cannot be imported. + Your existing setup will not be changed. [x] Tools & setup Settings, instructions, integrations, agents, commands, and skills Importing: Settings 1 diff --git a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_secondary_source_customize.snap b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_secondary_source_customize.snap new file mode 100644 index 000000000000..c344e341e6db --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_secondary_source_customize.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration_flow__tests__external_agent_config_migration_messages.snap b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration_flow__tests__external_agent_config_migration_messages.snap index 51367fe90a39..cf1e6ce6b42a 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration_flow__tests__external_agent_config_migration_messages.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration_flow__tests__external_agent_config_migration_messages.snap @@ -2,7 +2,7 @@ source: tui/src/external_agent_config_migration_flow_tests.rs 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. Importing: Settings: 1 @@ -10,7 +10,7 @@ expression: messages MCP servers: 2 — docs, issues Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes 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. Importing: Settings: 1 @@ -19,7 +19,7 @@ expression: messages Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes Plugins: 2 — formatter, reviewer 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. Importing: Settings: 1 @@ -28,12 +28,12 @@ expression: messages Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes Plugins: 2 — formatter, reviewer 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: Settings: 1 imported, 0 failed Plugins: 1 imported, 1 failed Run /import again to check for additional items. -No Claude Code setup was found to import. -Import from Claude Code 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. -A previous Claude Code import is still running. Wait for it to finish before importing again. +No compatible setup was found to import. +Import from other apps is unavailable in remote sessions. Start Codex locally 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 external agent import is still running. Wait for it to finish before importing again. diff --git a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration_source__tests__external_agent_config_source_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration_source__tests__external_agent_config_source_prompt.snap new file mode 100644 index 000000000000..0e994508bf4e --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration_source__tests__external_agent_config_source_prompt.snap @@ -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 diff --git a/codex-rs/utils/plugins/src/plugin_namespace.rs b/codex-rs/utils/plugins/src/plugin_namespace.rs index 4604942ffe97..6a949cae1f85 100644 --- a/codex-rs/utils/plugins/src/plugin_namespace.rs +++ b/codex-rs/utils/plugins/src/plugin_namespace.rs @@ -7,8 +7,11 @@ use std::path::Path; use std::path::PathBuf; /// Ordered plugin manifest paths recognized beneath a plugin root. -pub const DISCOVERABLE_PLUGIN_MANIFEST_PATHS: &[&str] = - &[".codex-plugin/plugin.json", ".claude-plugin/plugin.json"]; +pub const DISCOVERABLE_PLUGIN_MANIFEST_PATHS: &[&str] = &[ + ".codex-plugin/plugin.json", + ".claude-plugin/plugin.json", + ".cursor-plugin/plugin.json", +]; pub fn find_plugin_manifest_path(plugin_root: &Path) -> Option { DISCOVERABLE_PLUGIN_MANIFEST_PATHS @@ -86,7 +89,8 @@ mod tests { use std::fs; 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] async fn uses_manifest_name() { @@ -114,7 +118,27 @@ mod tests { 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_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(manifest_path.parent().expect("manifest parent"))