From daa735cfea6ddbfced09e827b3504c2a0948dc42 Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Thu, 9 Jul 2026 20:14:50 -0400 Subject: [PATCH] complete secondary import migrations --- codex-rs/Cargo.lock | 1 + .../src/config/external_agent_config.rs | 210 ++++++++++++---- .../config/external_agent_config/source.rs | 14 +- .../config/external_agent_config/source_cu.rs | 211 +++++++++++++++- .../tests/suite/v2/external_agent_config.rs | 3 + .../v2/external_agent_config_source_cu.rs | 225 ++++++++++++++++++ codex-rs/core-plugins/src/manifest.rs | 89 ++++++- codex-rs/core-plugins/src/marketplace.rs | 9 +- .../core-plugins/src/marketplace_tests.rs | 46 ++++ .../src/json_hooks.rs | 151 ++++++++++++ codex-rs/external-agent-migration/src/lib.rs | 60 +++-- codex-rs/external-agent-sessions/Cargo.toml | 1 + .../src/detect_cursor.rs | 182 ++++++++++++++ .../src/detect_cursor_tests.rs | 51 ++++ .../external-agent-sessions/src/export.rs | 7 +- codex-rs/external-agent-sessions/src/lib.rs | 7 +- .../external-agent-sessions/src/records.rs | 57 ++++- .../utils/plugins/src/plugin_namespace.rs | 28 ++- 18 files changed, 1250 insertions(+), 102 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/external_agent_config_source_cu.rs create mode 100644 codex-rs/external-agent-migration/src/json_hooks.rs create mode 100644 codex-rs/external-agent-sessions/src/detect_cursor.rs create mode 100644 codex-rs/external-agent-sessions/src/detect_cursor_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 023c5dfaa3..4cfefe8d0b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3055,6 +3055,7 @@ dependencies = [ "codex-app-server-protocol", "codex-protocol", "codex-utils-output-truncation", + "pretty_assertions", "serde", "serde_json", "sha2 0.10.9", diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index f5b846c309..8f9a45d83c 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -20,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_sessions::ExternalAgentSessionMigration; +use codex_external_agent_sessions::detect_recent_cursor_sessions; use codex_external_agent_sessions::detect_recent_sessions; use codex_plugin::PluginId; use codex_protocol::protocol::Product; @@ -479,7 +480,7 @@ impl ExternalAgentConfigService { let cwd = repo_root.map(Path::to_path_buf); let source_settings = self.source_settings(repo_root); let settings = if self.source.supports(SourceFeature::Config) { - effective_external_settings(&source_settings)? + self.effective_source_settings(repo_root)? } else { None }; @@ -488,7 +489,7 @@ impl ExternalAgentConfigService { |repo_root| repo_root.join(".codex").join("config.toml"), ); if let Some(settings) = settings.as_ref() { - let migrated = build_config_from_external(settings)?; + let migrated = build_config_from_external(settings, self.source)?; if !is_empty_toml_table(&migrated) { let mut should_include = true; if target_config.exists() { @@ -719,9 +720,7 @@ impl ExternalAgentConfigService { ); } - if self.source.supports(SourceFeature::Plugins) - && let Some(settings) = settings.as_ref() - { + if self.source.supports(SourceFeature::Plugins) { match ConfigBuilder::default() .codex_home(self.codex_home.clone()) .fallback_cwd(Some(self.codex_home.clone())) @@ -748,14 +747,26 @@ impl ExternalAgentConfigService { &config, &PluginsManager::new(self.codex_home.clone()), )?; - if let Some(item) = self.detect_plugin_migration( - source_settings.as_path(), - repo_root.unwrap_or(self.external_agent_home.as_path()), - cwd.clone(), - settings, - &configured_plugin_ids, - &configured_marketplace_plugins, - ) { + let item = match self.source { + ExternalAgentSource::Cl => settings.as_ref().and_then(|settings| { + self.detect_plugin_migration( + source_settings.as_path(), + repo_root.unwrap_or(self.external_agent_home.as_path()), + cwd.clone(), + settings, + &configured_plugin_ids, + &configured_marketplace_plugins, + ) + }), + ExternalAgentSource::Cu if repo_root.is_none() => self + .detect_cursor_plugin_migration( + cwd.clone(), + &configured_plugin_ids, + &configured_marketplace_plugins, + )?, + ExternalAgentSource::Cu => None, + }; + if let Some(item) = item { items.push(item); } } @@ -770,7 +781,14 @@ impl ExternalAgentConfigService { } if repo_root.is_none() && self.source.supports(SourceFeature::Sessions) { - let sessions = detect_recent_sessions(&self.external_agent_home, &self.codex_home)?; + let sessions = match self.source { + ExternalAgentSource::Cl => { + detect_recent_sessions(&self.external_agent_home, &self.codex_home)? + } + ExternalAgentSource::Cu => { + detect_recent_cursor_sessions(&self.external_agent_home, &self.codex_home)? + } + }; if !sessions.is_empty() { items.push(ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Sessions, @@ -810,7 +828,19 @@ impl ExternalAgentConfigService { } fn source_settings(&self, repo_root: Option<&Path>) -> PathBuf { - self.source_config_dir(repo_root).join("settings.json") + self.source_config_dir(repo_root) + .join(self.source.settings_file_name(repo_root.is_some())) + } + + fn effective_source_settings(&self, repo_root: Option<&Path>) -> io::Result> { + let source_settings = self.source_settings(repo_root); + match self.source { + ExternalAgentSource::Cl => effective_external_settings(&source_settings), + ExternalAgentSource::Cu => source_cu::effective_settings( + self.source_config_dir(repo_root).as_path(), + &source_settings, + ), + } } fn build_mcp_config( @@ -849,8 +879,8 @@ impl ExternalAgentConfigService { source_settings: Option, ) -> io::Result> { if repo_root.is_some() && source_settings.is_none() { - let home_settings = self.external_agent_home.join("settings.json"); - match effective_external_settings(&home_settings) { + let home_settings = self.source_settings(/*repo_root*/ None); + match self.effective_source_settings(/*repo_root*/ None) { Ok(settings) => Ok(settings), Err(err) => { tracing::warn!( @@ -908,16 +938,86 @@ impl ExternalAgentConfigService { }) } + fn detect_cursor_plugin_migration( + &self, + cwd: Option, + configured_plugin_ids: &HashSet, + configured_marketplace_plugins: &BTreeMap>, + ) -> io::Result> { + let mut plugins = Vec::new(); + for marketplace in source_cu::cached_marketplace_plugins(&self.external_agent_home)? { + let configured_marketplace = configured_marketplace_plugins.get(&marketplace.name); + let plugin_names = marketplace + .plugin_names + .into_iter() + .filter(|plugin_name| { + !configured_plugin_ids.contains(&format!("{plugin_name}@{}", marketplace.name)) + && !configured_marketplace + .is_some_and(|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); + } + emit_migration_metric( + EXTERNAL_AGENT_CONFIG_DETECT_METRIC, + ExternalAgentConfigMigrationItemType::Plugins, + /*skills_count*/ None, + ); + Ok(Some(ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: format!( + "Migrate cached plugins from {}", + self.external_agent_home.join("plugins/cache").display() + ), + cwd, + details: Some(MigrationDetails { + plugins, + ..Default::default() + }), + })) + } + + fn cursor_marketplace_import_sources( + &self, + ) -> io::Result> { + Ok( + source_cu::cached_marketplace_plugins(&self.external_agent_home)? + .into_iter() + .map(|marketplace| { + ( + marketplace.name, + MarketplaceImportSource { + source: marketplace.source.display().to_string(), + ref_name: None, + }, + ) + }) + .collect(), + ) + } + fn partition_plugin_migration_details( &self, cwd: Option<&Path>, details: MigrationDetails, ) -> io::Result<(Option, Option)> { - let source_settings = self.source_settings(cwd); - let source_root = cwd.unwrap_or(self.external_agent_home.as_path()); - let import_sources = effective_external_settings(&source_settings)? - .map(|settings| self.marketplace_import_sources(&settings, source_root)) - .unwrap_or_default(); + let import_sources = match self.source { + ExternalAgentSource::Cl => { + let source_root = cwd.unwrap_or(self.external_agent_home.as_path()); + self.effective_source_settings(cwd)? + .map(|settings| self.marketplace_import_sources(&settings, source_root)) + .unwrap_or_default() + } + ExternalAgentSource::Cu => self.cursor_marketplace_import_sources()?, + }; let mut local_plugins = Vec::new(); let mut remote_plugins = Vec::new(); @@ -978,11 +1078,15 @@ impl ExternalAgentConfigService { if let Some(analytics_events_client) = self.analytics_events_client.clone() { plugins_manager.set_analytics_events_client(analytics_events_client); } - let source_settings = self.source_settings(cwd); - let source_root = cwd.unwrap_or(self.external_agent_home.as_path()); - let import_sources = effective_external_settings(&source_settings)? - .map(|settings| self.marketplace_import_sources(&settings, source_root)) - .unwrap_or_default(); + let import_sources = match self.source { + ExternalAgentSource::Cl => { + let source_root = cwd.unwrap_or(self.external_agent_home.as_path()); + self.effective_source_settings(cwd)? + .map(|settings| self.marketplace_import_sources(&settings, source_root)) + .unwrap_or_default() + } + ExternalAgentSource::Cu => self.cursor_marketplace_import_sources()?, + }; for plugin_group in plugins { let marketplace_name = plugin_group.marketplace_name.clone(); let plugin_names = plugin_group.plugin_names; @@ -1215,14 +1319,14 @@ impl ExternalAgentConfigService { return Ok(None); } else { ( - self.external_agent_home.join("settings.json"), + self.source_settings(/*repo_root*/ None), self.codex_home.join("config.toml"), ) }; - let Some(settings) = effective_external_settings(&source_settings)? else { + let Some(settings) = self.effective_source_settings(repo_root.as_deref())? else { return Ok(None); }; - let migrated = build_config_from_external(&settings)?; + let migrated = build_config_from_external(&settings, self.source)?; if is_empty_toml_table(&migrated) { return Ok(None); } @@ -1261,21 +1365,15 @@ impl ExternalAgentConfigService { fn import_mcp_server_config(&self, cwd: Option<&Path>) -> io::Result> { let repo_root = find_repo_root(cwd)?; - let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() { - ( - self.source_settings(Some(repo_root)), - repo_root.join(".codex").join("config.toml"), - ) + let target_config = if let Some(repo_root) = repo_root.as_ref() { + repo_root.join(".codex").join("config.toml") } else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) { return Ok(Vec::new()); } else { - ( - self.external_agent_home.join("settings.json"), - self.codex_home.join("config.toml"), - ) + self.codex_home.join("config.toml") }; let settings = if self.source.supports(SourceFeature::Config) { - effective_external_settings(&source_settings)? + self.effective_source_settings(repo_root.as_deref())? } else { None }; @@ -1840,7 +1938,10 @@ fn is_non_empty_text_file(path: &Path) -> io::Result { Ok(!fs::read_to_string(path)?.trim().is_empty()) } -fn build_config_from_external(settings: &JsonValue) -> io::Result { +fn build_config_from_external( + settings: &JsonValue, + source: ExternalAgentSource, +) -> io::Result { let Some(settings_obj) = settings.as_object() else { return Err(invalid_data_error( "external agent settings root must be an object", @@ -1864,17 +1965,22 @@ fn build_config_from_external(settings: &JsonValue) -> io::Result { ); } - if let Some(sandbox_enabled) = settings_obj - .get("sandbox") - .and_then(JsonValue::as_object) - .and_then(|sandbox| sandbox.get("enabled")) - .and_then(JsonValue::as_bool) - && sandbox_enabled - { - root.insert( - "sandbox_mode".to_string(), - TomlValue::String("workspace-write".to_string()), - ); + match source { + ExternalAgentSource::Cl => { + if settings_obj + .get("sandbox") + .and_then(JsonValue::as_object) + .and_then(|sandbox| sandbox.get("enabled")) + .and_then(JsonValue::as_bool) + == Some(true) + { + root.insert( + "sandbox_mode".to_string(), + TomlValue::String("workspace-write".to_string()), + ); + } + } + ExternalAgentSource::Cu => source_cu::append_sandbox_config(&mut root, settings_obj), } Ok(TomlValue::Table(root)) diff --git a/codex-rs/app-server/src/config/external_agent_config/source.rs b/codex-rs/app-server/src/config/external_agent_config/source.rs index f5fd5e03de..89dd8d1e60 100644 --- a/codex-rs/app-server/src/config/external_agent_config/source.rs +++ b/codex-rs/app-server/src/config/external_agent_config/source.rs @@ -53,7 +53,15 @@ impl ExternalAgentSource { ( Self::Cu, SourceFeature::Config | SourceFeature::Plugins | SourceFeature::Sessions, - ) => false, + ) => true, + } + } + + pub(super) fn settings_file_name(self, project_scope: bool) -> &'static str { + match (self, project_scope) { + (Self::Cl, _) => "settings.json", + (Self::Cu, false) => source_cu::HOME_CONFIG_FILE, + (Self::Cu, true) => source_cu::PROJECT_CONFIG_FILE, } } @@ -159,14 +167,14 @@ impl ExternalAgentSource { ) -> io::Result> { match self { Self::Cl => source_cl::source_hook_event_names(source_dir, target_hooks), - Self::Cu => Ok(Vec::new()), + Self::Cu => source_cu::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::Cl => source_cl::import_source_hooks(source_dir, target_hooks), - Self::Cu => Ok(false), + Self::Cu => source_cu::import_source_hooks(source_dir, target_hooks), } } diff --git a/codex-rs/app-server/src/config/external_agent_config/source_cu.rs b/codex-rs/app-server/src/config/external_agent_config/source_cu.rs index bc50c09d72..b77dd24ba0 100644 --- a/codex-rs/app-server/src/config/external_agent_config/source_cu.rs +++ b/codex-rs/app-server/src/config/external_agent_config/source_cu.rs @@ -3,9 +3,13 @@ use codex_external_agent_migration::CommandMigrationProfile; use codex_external_agent_migration::RewriteProfile; use codex_external_agent_migration::build_mcp_config_from_json_file; use codex_external_agent_migration::count_missing_commands_with_profile; +use codex_external_agent_migration::hook_migration_event_names_from_json_file; use codex_external_agent_migration::import_commands_with_profile; +use codex_external_agent_migration::import_hooks_from_json_file; use codex_external_agent_migration::import_subagents_with_rewrite_profile; use codex_external_agent_migration::missing_command_names_with_profile; +use serde_json::Value as JsonValue; +use std::collections::BTreeSet; use std::fs; use std::io; use std::path::Path; @@ -17,25 +21,111 @@ use super::source::InstructionSourceGroup; pub(super) const CONFIG_DIR: &str = ".cursor"; pub(super) const LEGACY_RULES_FILE: &str = ".cursorrules"; -pub(super) const REWRITE_PROFILE: RewriteProfile = RewriteProfile::new( - LEGACY_RULES_FILE, - &[ - "cursor agent", - "cursor-agent", - "cursor_agent", - "cursoragent", - ], -) -.with_case_sensitive_term_variants(&["Cursor"]); +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"; +pub(super) const REWRITE_PROFILE: RewriteProfile = + RewriteProfile::new(LEGACY_RULES_FILE, &[]).with_case_sensitive_term_variants(&["Cursor"]); const COMMAND_MIGRATION_PROFILE: CommandMigrationProfile = CommandMigrationProfile::new( REWRITE_PROFILE, 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 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_sandbox_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 repo_instruction_source_groups( repo_root: &Path, ) -> io::Result> { @@ -101,6 +191,107 @@ pub(super) fn import_source_subagents( 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_from_json_file( + 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_from_json_file( + 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(".cursor-plugin/marketplace.json"); + 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) +} + fn instruction_sources( legacy_rules: Option, rules_dir: &Path, 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 53c50ea43f..a0d771ed9b 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 @@ -42,6 +42,9 @@ use super::analytics::wait_for_analytics_event; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); +#[path = "external_agent_config_source_cu.rs"] +mod source_cu_tests; + fn external_agent_home(codex_home: &Path) -> PathBuf { codex_home.join(concat!(".", "cl", "aude")) } diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config_source_cu.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config_source_cu.rs new file mode 100644 index 0000000000..5fc1008737 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config_source_cu.rs @@ -0,0 +1,225 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn imports_cursor_config_plugins_hooks_sessions_and_capitalized_text() -> Result<()> { + let root = TempDir::new()?; + let home = root.path().join("home"); + let codex_home = home.join(".codex"); + let source_dir = home.join(".cursor"); + let project_root = root.path().join("workspace-with-dashes"); + let writable_root = root.path().join("shared-cache"); + std::fs::create_dir_all(&codex_home)?; + std::fs::create_dir_all(&source_dir)?; + std::fs::create_dir_all(&project_root)?; + std::fs::create_dir_all(project_root.join(".git"))?; + std::fs::create_dir_all(&writable_root)?; + + std::fs::write( + source_dir.join("cli-config.json"), + r#"{"permissions":{"allow":["Shell(git)"]}}"#, + )?; + std::fs::write( + source_dir.join("sandbox.json"), + serde_json::to_string(&serde_json::json!({ + "type": "workspace_readwrite", + "additionalReadwritePaths": [writable_root], + "disableTmpWrite": true, + "networkPolicy": {"default": "allow"} + }))?, + )?; + + std::fs::create_dir_all(source_dir.join("skills/release"))?; + std::fs::write( + source_dir.join("skills/release/SKILL.md"), + concat!( + "---\nname: release\ndescription: Release with Cursor\n---\n\n", + "Use Cursor to release. Keep the database cursor and CURSOR acronym unchanged.\n" + ), + )?; + + std::fs::create_dir_all(source_dir.join("hooks"))?; + std::fs::write( + source_dir.join("hooks/session-start.sh"), + "#!/bin/sh\nexit 0\n", + )?; + std::fs::write( + source_dir.join("hooks.json"), + r#"{ + "version": 1, + "hooks": { + "sessionStart": [{ + "command": "sh .cursor/hooks/session-start.sh", + "statusMessage": "Starting Cursor context" + }], + "preToolUse": [{ + "command": "sh .cursor/hooks/session-start.sh", + "matcher": "Shell" + }], + "afterAgentResponse": [{"command": "echo unsupported"}] + } + }"#, + )?; + + let marketplace_root = source_dir.join("plugins/marketplaces/team-tools"); + let plugin_root = marketplace_root.join("release-helper"); + 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(plugin_root.join("skills/plugin-release"))?; + std::fs::write( + marketplace_root.join(".cursor-plugin/marketplace.json"), + r#"{ + "name": "team-tools", + "plugins": [{"name": "release-helper", "source": "release-helper"}] + }"#, + )?; + std::fs::write( + plugin_root.join(".cursor-plugin/plugin.json"), + r#"{ + "name": "release-helper", + "version": "1.0.0", + "skills": "skills" + }"#, + )?; + std::fs::write( + plugin_root.join("skills/plugin-release/SKILL.md"), + "---\nname: plugin-release\ndescription: Release helper\n---\n", + )?; + std::fs::create_dir_all(source_dir.join("plugins/cache/team-tools/release-helper/revision"))?; + + let session_id = "11111111-1111-1111-1111-111111111111"; + let session_path = source_dir + .join("projects") + .join(encoded_cursor_project_path(&project_root)) + .join("agent-transcripts") + .join(session_id) + .join(format!("{session_id}.jsonl")); + std::fs::create_dir_all(session_path.parent().expect("session parent"))?; + std::fs::write( + &session_path, + concat!( + "{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"review the release\"}]}}\n", + "{\"role\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"release looks good\"}]}}\n" + ), + )?; + + let home_dir = home.display().to_string(); + let mut mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .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, + "cwds": [project_root], + "source": "cursor" + })), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let detected: ExternalAgentConfigDetectResponse = to_response(response)?; + let detected_types = detected + .items + .iter() + .map(|item| item.item_type) + .collect::>(); + for expected in [ + ExternalAgentConfigMigrationItemType::Config, + ExternalAgentConfigMigrationItemType::Hooks, + ExternalAgentConfigMigrationItemType::Skills, + ExternalAgentConfigMigrationItemType::Plugins, + ExternalAgentConfigMigrationItemType::Sessions, + ] { + assert!(detected_types.contains(&expected), "missing {expected:?}"); + } + assert_eq!( + detected_types + .iter() + .filter(|item_type| **item_type == ExternalAgentConfigMigrationItemType::Plugins) + .count(), + 1 + ); + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ + "migrationItems": detected.items, + "source": "cursor" + })), + ) + .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!( + completed + .item_type_results + .iter() + .all(|result| result.failures.is_empty()), + "{:#?}", + completed.item_type_results + ); + let session_result = completed + .item_type_results + .iter() + .find(|result| result.item_type == ExternalAgentConfigMigrationItemType::Sessions) + .expect("session result"); + assert_eq!(session_result.successes.len(), 1); + + let config = std::fs::read_to_string(codex_home.join("config.toml"))?; + assert!(config.contains("sandbox_mode = \"workspace-write\"")); + assert!(config.contains("network_access = true")); + assert!(config.contains("exclude_slash_tmp = true")); + assert!(config.contains(writable_root.to_string_lossy().as_ref())); + assert!(config.contains(r#"[plugins."release-helper@team-tools"]"#)); + + let skill = std::fs::read_to_string(home.join(".agents/skills/release/SKILL.md"))?; + assert!(skill.contains("Release with Codex")); + assert!(skill.contains("Use Codex to release")); + assert!(skill.contains("database cursor")); + assert!(skill.contains("CURSOR acronym")); + + let hooks = std::fs::read_to_string(codex_home.join("hooks.json"))?; + assert!(hooks.contains("SessionStart")); + assert!(hooks.contains("PreToolUse")); + assert!(hooks.contains("Shell")); + assert!(hooks.contains("Starting Codex context")); + assert!(!hooks.contains("afterAgentResponse")); + assert!(codex_home.join("hooks/session-start.sh").is_file()); + + Ok(()) +} + +#[cfg(not(windows))] +fn encoded_cursor_project_path(path: &Path) -> String { + path.to_string_lossy() + .trim_start_matches('/') + .replace('/', "-") +} + +#[cfg(windows)] +fn encoded_cursor_project_path(path: &Path) -> String { + path.to_string_lossy().replace([':', '\\'], "-") +} diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index 0edebe3699..e7a7061b0f 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -162,12 +162,18 @@ pub(crate) fn parse_plugin_manifest_uri( version, description, keywords, - skills, - mcp_servers, - apps, - hooks, + mut skills, + mut mcp_servers, + mut apps, + mut hooks, interface, } = serde_json::from_str::(contents)?; + if manifest_path + .to_string() + .ends_with(".cursor-plugin/plugin.json") + { + normalize_cursor_manifest_paths(&mut skills, &mut mcp_servers, &mut apps, &mut hooks); + } let name = plugin_root .basename() .filter(|_| raw_name.trim().is_empty()) @@ -267,6 +273,45 @@ pub(crate) fn parse_plugin_manifest_uri( }) } +fn normalize_cursor_manifest_paths( + skills: &mut Option, + mcp_servers: &mut Option, + apps: &mut Option, + hooks: &mut Option, +) { + match skills { + Some(RawPluginManifestPaths::Path(path)) => normalize_cursor_manifest_path(path), + Some(RawPluginManifestPaths::Paths(paths)) => { + paths.iter_mut().for_each(normalize_cursor_manifest_path); + } + Some(RawPluginManifestPaths::Invalid(_)) | None => {} + } + if let Some(RawPluginManifestMcpServers::Path(path)) = mcp_servers { + normalize_cursor_manifest_path(path); + } + if let Some(apps) = apps { + normalize_cursor_manifest_path(apps); + } + match hooks { + Some(RawPluginManifestHooks::Path(path)) => normalize_cursor_manifest_path(path), + Some(RawPluginManifestHooks::Paths(paths)) => { + paths.iter_mut().for_each(normalize_cursor_manifest_path); + } + Some( + RawPluginManifestHooks::Inline(_) + | RawPluginManifestHooks::InlineList(_) + | RawPluginManifestHooks::Invalid(_), + ) + | None => {} + } +} + +fn normalize_cursor_manifest_path(path: &mut String) { + if !path.starts_with("./") { + path.insert_str(0, "./"); + } +} + fn resolve_manifest_hooks( plugin_root: &PathUri, hooks: Option, @@ -532,6 +577,7 @@ mod tests { use crate::ExecutorPluginProvider; const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json"; + const CURSOR_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".cursor-plugin/plugin.json"; fn write_manifest(plugin_root: &Path, version: Option<&str>, interface: &str) { fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); @@ -558,6 +604,13 @@ mod tests { fs::write(manifest_path, contents).expect("write manifest"); } + fn write_cursor_plugin_manifest(plugin_root: &Path, contents: &str) { + let manifest_path = plugin_root.join(CURSOR_PLUGIN_MANIFEST_RELATIVE_PATH); + fs::create_dir_all(manifest_path.parent().expect("manifest parent")) + .expect("create manifest dir"); + fs::write(manifest_path, contents).expect("write manifest"); + } + fn load_manifest(plugin_root: &Path) -> PluginManifest { load_plugin_manifest(plugin_root).expect("load plugin manifest") } @@ -732,6 +785,34 @@ mod tests { ); } + #[test] + fn cursor_plugin_manifest_accepts_bare_relative_paths() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + write_cursor_plugin_manifest( + &plugin_root, + r#"{ + "name": "demo-plugin", + "skills": "skills", + "hooks": "hooks/hooks.json" +}"#, + ); + + let manifest = load_manifest(&plugin_root); + + assert_eq!( + manifest.paths.skills, + vec![AbsolutePathBuf::try_from(plugin_root.join("skills")).expect("absolute skills")] + ); + assert_eq!( + manifest.paths.hooks, + Some(PluginManifestHooks::Paths(vec![ + AbsolutePathBuf::try_from(plugin_root.join("hooks/hooks.json")) + .expect("absolute hooks") + ])) + ); + } + #[test] fn uri_manifest_uses_the_root_path_convention() { let windows_root = diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index 6475d37876..b3ef42c8e9 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 d30e9ad1da..7adcfd1bd1 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 CURSOR_MARKETPLACE_RELATIVE_PATH: &str = ".cursor-plugin/marketplace.json"; +const CURSOR_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_cursor_marketplace(repo_root: &Path, contents: &str) -> AbsolutePathBuf { + let marketplace_path = repo_root.join(CURSOR_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_cursor_plugin_manifest(plugin_root: &Path, contents: &str) { + let manifest_path = plugin_root.join(CURSOR_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_cursor_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_cursor_marketplace( + &repo_root, + r#"{ + "name": "cursor-marketplace", + "plugins": [{"name": "sample", "source": "plugins/sample"}] +}"#, + ); + write_cursor_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/json_hooks.rs b/codex-rs/external-agent-migration/src/json_hooks.rs new file mode 100644 index 0000000000..38b8baaca1 --- /dev/null +++ b/codex-rs/external-agent-migration/src/json_hooks.rs @@ -0,0 +1,151 @@ +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_from_json_file( + source_external_agent_dir: &Path, + source_hooks: &Path, + target_hooks: &Path, + rewrite_profile: RewriteProfile, +) -> io::Result> { + let migration = hook_migration_from_json_file( + source_external_agent_dir, + source_hooks, + target_hooks.parent(), + rewrite_profile, + )?; + Ok(migration.keys().cloned().collect()) +} + +pub fn import_hooks_from_json_file( + source_external_agent_dir: &Path, + source_hooks: &Path, + target_hooks: &Path, + rewrite_profile: RewriteProfile, +) -> io::Result { + let migration = hook_migration_from_json_file( + source_external_agent_dir, + source_hooks, + target_hooks.parent(), + rewrite_profile, + )?; + write_hook_migration(source_external_agent_dir, target_hooks, migration) +} + +fn hook_migration_from_json_file( + 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; + }; + if handler.keys().any(|key| { + !matches!( + key.as_str(), + "command" | "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; + }; + + 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"), + "sessionStart" => Some("SessionStart"), + "beforeSubmitPrompt" => Some("UserPromptSubmit"), + "stop" => Some("Stop"), + _ => None, + } +} diff --git a/codex-rs/external-agent-migration/src/lib.rs b/codex-rs/external-agent-migration/src/lib.rs index de57f3fed8..890eb17b2c 100644 --- a/codex-rs/external-agent-migration/src/lib.rs +++ b/codex-rs/external-agent-migration/src/lib.rs @@ -1,5 +1,7 @@ //! Migration helpers for importing external-agent configuration into Codex. +mod json_hooks; + use codex_hooks::HOOK_EVENT_NAMES; use codex_hooks::HOOK_EVENT_NAMES_WITH_MATCHERS; use serde_json::Value as JsonValue; @@ -12,6 +14,9 @@ use std::path::Path; use std::path::PathBuf; use toml::Value as TomlValue; +pub use json_hooks::hook_migration_event_names_from_json_file; +pub use json_hooks::import_hooks_from_json_file; + const SOURCE_EXTERNAL_AGENT_NAME: &str = "claude"; const EXTERNAL_AGENT_MCP_CONFIG_FILE: &str = ".mcp.json"; const EXTERNAL_AGENT_HOOKS_SUBDIR: &str = "hooks"; @@ -211,20 +216,7 @@ pub fn import_hooks( return Ok(false); } - fs::create_dir_all(parent)?; - - let mut wrote_active_hooks = false; - if is_missing_or_empty_text_file(target_hooks)? { - copy_hook_scripts(source_external_agent_dir, parent)?; - let mut payload = serde_json::Map::new(); - payload.insert("hooks".to_string(), JsonValue::Object(migration)); - let rendered = serde_json::to_string_pretty(&JsonValue::Object(payload)) - .map_err(|err| invalid_data_error(format!("failed to serialize hooks.json: {err}")))?; - fs::write(target_hooks, format!("{rendered}\n"))?; - wrote_active_hooks = true; - } - - Ok(wrote_active_hooks) + write_hook_migration(source_external_agent_dir, target_hooks, migration) } pub fn count_missing_subagents(source_agents: &Path, target_agents: &Path) -> io::Result { @@ -664,6 +656,27 @@ fn hook_migration( Ok(migration) } +fn write_hook_migration( + source_external_agent_dir: &Path, + target_hooks: &Path, + migration: serde_json::Map, +) -> io::Result { + if migration.is_empty() || !is_missing_or_empty_text_file(target_hooks)? { + return Ok(false); + } + let Some(parent) = target_hooks.parent() else { + return Err(invalid_data_error("hooks target path has no parent")); + }; + fs::create_dir_all(parent)?; + copy_hook_scripts(source_external_agent_dir, parent)?; + let mut payload = serde_json::Map::new(); + payload.insert("hooks".to_string(), JsonValue::Object(migration)); + let rendered = serde_json::to_string_pretty(&JsonValue::Object(payload)) + .map_err(|err| invalid_data_error(format!("failed to serialize hooks.json: {err}")))?; + fs::write(target_hooks, format!("{rendered}\n"))?; + Ok(true) +} + fn append_convertible_hook_groups( settings: &JsonValue, hooks_payload: &mut serde_json::Map, @@ -794,6 +807,15 @@ fn append_convertible_hook_groups( } fn rewrite_hook_command(command: &str, target_config_dir: Option<&Path>) -> String { + let source_external_agent_dir = PathBuf::from(external_agent_config_dir()); + rewrite_hook_command_for_source(command, target_config_dir, &source_external_agent_dir) +} + +fn rewrite_hook_command_for_source( + command: &str, + target_config_dir: Option<&Path>, + source_external_agent_dir: &Path, +) -> String { let Some(target_config_dir) = target_config_dir else { return command.to_string(); }; @@ -801,10 +823,12 @@ fn rewrite_hook_command(command: &str, target_config_dir: Option<&Path>) -> Stri return command.to_string(); } let target_hooks_dir = target_config_dir.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR); - let source_hooks_path = format!( - "{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/", - external_agent_config_dir() - ); + let source_config_dir = source_external_agent_dir + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string) + .unwrap_or_else(external_agent_config_dir); + let source_hooks_path = format!("{source_config_dir}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/"); let command = replace_quoted_hook_paths(command, '\'', &source_hooks_path, &target_hooks_dir); let command = replace_quoted_hook_paths(&command, '"', &source_hooks_path, &target_hooks_dir); replace_unquoted_hook_paths(&command, &source_hooks_path, &target_hooks_dir) diff --git a/codex-rs/external-agent-sessions/Cargo.toml b/codex-rs/external-agent-sessions/Cargo.toml index c3639f8e85..2bf6eaffa2 100644 --- a/codex-rs/external-agent-sessions/Cargo.toml +++ b/codex-rs/external-agent-sessions/Cargo.toml @@ -22,4 +22,5 @@ sha2 = { workspace = true } [dev-dependencies] codex-app-server-protocol = { workspace = true } +pretty_assertions = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/external-agent-sessions/src/detect_cursor.rs b/codex-rs/external-agent-sessions/src/detect_cursor.rs new file mode 100644 index 0000000000..008f6ddf63 --- /dev/null +++ b/codex-rs/external-agent-sessions/src/detect_cursor.rs @@ -0,0 +1,182 @@ +use crate::ExternalAgentSessionMigration; +use crate::ledger::load_import_ledger; +use crate::ledger::save_import_ledger; +use crate::now_unix_seconds; +use crate::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_cursor_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 Some(cwd) = cursor_project_cwd(&project_storage) else { + continue; + }; + for path in cursor_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, 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, 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, Some(&cwd)) else { + continue; + }; + migrations.push(summary.migration); + } + if ledger_changed { + save_import_ledger(codex_home, &ledger)?; + } + Ok(migrations) +} + +fn cursor_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 cursor_project_cwd(project_storage: &Path) -> Option { + let encoded = project_storage.file_name()?.to_str()?; + decode_cursor_project_path(encoded) +} + +#[cfg(not(windows))] +fn decode_cursor_project_path(encoded: &str) -> Option { + decode_cursor_project_path_from(encoded, Path::new("/"), /*depth*/ 0).or_else(|| { + encoded.strip_prefix('-').and_then(|encoded| { + decode_cursor_project_path_from(encoded, Path::new("/"), /*depth*/ 0) + }) + }) +} + +#[cfg(windows)] +fn decode_cursor_project_path(encoded: &str) -> Option { + let (drive, remaining) = encoded.split_once('-')?; + if drive.len() != 1 || !drive.as_bytes()[0].is_ascii_alphabetic() { + return None; + } + let base = PathBuf::from(format!("{drive}:\\")); + decode_cursor_project_path_from(remaining, &base, /*depth*/ 0).or_else(|| { + remaining.strip_prefix('-').and_then(|remaining| { + decode_cursor_project_path_from(remaining, &base, /*depth*/ 0) + }) + }) +} + +fn decode_cursor_project_path_from(encoded: &str, base: &Path, depth: usize) -> Option { + if encoded.is_empty() || depth > 32 { + return None; + } + let whole = base.join(encoded); + if whole.is_dir() { + return Some(whole); + } + for (index, _) in encoded.match_indices('-') { + let segment = &encoded[..index]; + if segment.is_empty() { + continue; + } + let candidate = base.join(segment); + if !candidate.is_dir() { + continue; + } + if let Some(path) = + decode_cursor_project_path_from(&encoded[index + 1..], &candidate, depth + 1) + { + return Some(path); + } + } + None +} + +#[cfg(test)] +#[path = "detect_cursor_tests.rs"] +mod tests; diff --git a/codex-rs/external-agent-sessions/src/detect_cursor_tests.rs b/codex-rs/external-agent-sessions/src/detect_cursor_tests.rs new file mode 100644 index 0000000000..a3da5c17db --- /dev/null +++ b/codex-rs/external-agent-sessions/src/detect_cursor_tests.rs @@ -0,0 +1,51 @@ +use super::*; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +#[test] +fn detects_cursor_transcript_with_project_cwd() { + let root = TempDir::new().expect("tempdir"); + let project_root = root.path().join("workspace-with-dashes"); + fs::create_dir_all(&project_root).expect("project root"); + let external_agent_home = root.path().join(".cursor"); + let encoded_project = encode_project_path(&project_root); + let session_id = "a-session"; + 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, + concat!( + "{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"first request\"}]}}\n", + "{\"role\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"first answer\"}]}}\n" + ), + ) + .expect("transcript"); + + let sessions = + detect_recent_cursor_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()), + }] + ); +} + +#[cfg(not(windows))] +fn encode_project_path(path: &Path) -> String { + path.to_string_lossy().replace('/', "-") +} + +#[cfg(windows)] +fn encode_project_path(path: &Path) -> String { + path.to_string_lossy().replace([':', '\\'], "-") +} diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 992770f855..71b06bb426 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -1,7 +1,7 @@ use crate::ConversationMessage; use crate::ImportedExternalAgentSession; use crate::MessageRole; -use crate::records::read_session_import; +use crate::records::read_session_import_with_cwd; use crate::summarize_for_label; use crate::title::IMPORTED_SESSION_FALLBACK_TITLE; use crate::title::SessionTitleCandidates; @@ -26,15 +26,16 @@ 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)? + load_session_for_import_with_content_sha256(path, /*fallback_cwd*/ None)? .map(|(session, _content_sha256)| 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-sessions/src/lib.rs b/codex-rs/external-agent-sessions/src/lib.rs index b13322dffb..9b51aebfa6 100644 --- a/codex-rs/external-agent-sessions/src/lib.rs +++ b/codex-rs/external-agent-sessions/src/lib.rs @@ -1,6 +1,7 @@ //! Parsing and export helpers for external-agent session histories. mod detect; +mod detect_cursor; mod export; mod ledger; mod records; @@ -12,6 +13,7 @@ use std::path::Path; use std::path::PathBuf; pub use detect::detect_recent_sessions; +pub use detect_cursor::detect_recent_cursor_sessions; use export::load_session_for_import_with_content_sha256; pub use ledger::CompletedExternalAgentSessionImport; pub use ledger::has_current_session_been_imported; @@ -52,7 +54,7 @@ pub fn prepare_validated_session_import( return Ok(None); } let Some((source_path, imported_session, source_content_sha256)) = - load_importable_session(&session.path)? + load_importable_session(&session.path, &session.cwd)? else { return Ok(None); }; @@ -65,10 +67,11 @@ pub fn prepare_validated_session_import( fn load_importable_session( path: &Path, + fallback_cwd: &Path, ) -> io::Result> { let source_path = std::fs::canonicalize(path)?; let Some((imported_session, source_content_sha256)) = - load_session_for_import_with_content_sha256(&source_path)? + load_session_for_import_with_content_sha256(&source_path, Some(fallback_cwd))? else { return Ok(None); }; diff --git a/codex-rs/external-agent-sessions/src/records.rs b/codex-rs/external-agent-sessions/src/records.rs index 2901714a19..69b8b83750 100644 --- a/codex-rs/external-agent-sessions/src/records.rs +++ b/codex-rs/external-agent-sessions/src/records.rs @@ -34,9 +34,17 @@ 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 cwd = fallback_cwd.map(Path::to_path_buf); let mut custom_title = None; let mut ai_title = None; let mut fallback_title = None; @@ -65,7 +73,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; @@ -107,10 +116,19 @@ pub fn summarize_session(path: &Path) -> io::Result> { })) } +#[cfg(test)] pub(super) fn read_session_import(path: &Path) -> io::Result { + read_session_import_with_cwd(path, /*fallback_cwd*/ None) +} + +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 cwd = fallback_cwd.map(Path::to_path_buf); let mut custom_title = None; let mut ai_title = None; let mut messages = Vec::new(); @@ -141,7 +159,9 @@ 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()?; +fn conversation_message_from_owned_record( + record: &mut JsonValue, + fallback_timestamp: Option, +) -> Option { + let record_type = record + .get("type") + .or_else(|| record.get("role"))? + .as_str()?; if record_type != "assistant" && record_type != "user" { return None; } @@ -185,7 +211,14 @@ fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option { @@ -210,6 +243,16 @@ fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option 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, diff --git a/codex-rs/utils/plugins/src/plugin_namespace.rs b/codex-rs/utils/plugins/src/plugin_namespace.rs index 4604942ffe..f4ac2ab29d 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 @@ -87,6 +90,7 @@ mod tests { use tempfile::tempdir; const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json"; + const CURSOR_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".cursor-plugin/plugin.json"; #[tokio::test] async fn uses_manifest_name() { @@ -128,4 +132,24 @@ mod tests { ); assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path)); } + + #[tokio::test] + async fn uses_name_from_cursor_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(CURSOR_PLUGIN_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)); + } }