diff --git a/codex-rs/external-agent-migration/src/detect/mod.rs b/codex-rs/external-agent-migration/src/detect/mod.rs index 9ca6b2851d..009e7ef9ef 100644 --- a/codex-rs/external-agent-migration/src/detect/mod.rs +++ b/codex-rs/external-agent-migration/src/detect/mod.rs @@ -189,22 +189,33 @@ impl ExternalAgentConfigService { ); } - let source_skills = repo_root.map_or_else( - || self.external_agent_home.join("skills"), - |repo_root| repo_root.join(self.source.config_dir()).join("skills"), - ); + let source_skills = self + .source + .skills_dir_names(scope) + .iter() + .map(|directory| source_external_agent_dir.join(*directory)) + .filter(|path| path.is_dir()) + .collect::>(); let target_skills = repo_root.map_or_else( || self.home_target_skills_dir(), |repo_root| repo_root.join(".agents").join("skills"), ); - let skill_names = missing_subdirectory_names(&source_skills, &target_skills)?; + let mut skill_names = Vec::new(); + for source_skills_dir in &source_skills { + skill_names.extend(missing_subdirectory_names( + source_skills_dir, + &target_skills, + )?); + } + skill_names.sort(); + skill_names.dedup(); let skills_count = skill_names.len(); if skills_count > 0 { items.push(ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Skills, description: format!( "Migrate skills from {} to {}", - source_skills.display(), + display_source_paths(&source_skills), target_skills.display() ), cwd: cwd.clone(), diff --git a/codex-rs/external-agent-migration/src/migration_source.rs b/codex-rs/external-agent-migration/src/migration_source.rs index dffde27107..d4c63f8a02 100644 --- a/codex-rs/external-agent-migration/src/migration_source.rs +++ b/codex-rs/external-agent-migration/src/migration_source.rs @@ -71,6 +71,14 @@ impl ExternalAgentSource { } } + pub(super) fn skills_dir_names(self, scope: &MigrationScope) -> &'static [&'static str] { + match (self, scope) { + // skills-cursor is Cursor-managed and only exists under the home config. + (Self::Cur, MigrationScope::Home) => &["skills", "skills-cursor"], + _ => &["skills"], + } + } + pub(super) fn supports_memory(self) -> bool { match self { Self::Cla => true, diff --git a/codex-rs/external-agent-migration/src/service.rs b/codex-rs/external-agent-migration/src/service.rs index f6dcf58400..889c098a03 100644 --- a/codex-rs/external-agent-migration/src/service.rs +++ b/codex-rs/external-agent-migration/src/service.rs @@ -682,37 +682,45 @@ impl ExternalAgentConfigService { let Some(scope) = MigrationScope::from_cwd(cwd)? else { return Ok(Vec::new()); }; - let (source_skills, target_skills) = match scope { + let source_skills_dir_names = self.source.skills_dir_names(&scope); + let (source_config_dir, target_skills) = match scope { MigrationScope::Home => ( - self.external_agent_home.join("skills"), + self.external_agent_home.clone(), self.home_target_skills_dir(), ), MigrationScope::Repository { root } => ( - root.join(self.source.config_dir()).join("skills"), + root.join(self.source.config_dir()), root.join(".agents").join("skills"), ), }; - if !source_skills.is_dir() { + let source_skills = source_skills_dir_names + .iter() + .map(|directory| source_config_dir.join(*directory)) + .filter(|path| path.is_dir()) + .collect::>(); + if source_skills.is_empty() { return Ok(Vec::new()); } fs::create_dir_all(&target_skills)?; let mut copied_names = Vec::new(); - for entry in fs::read_dir(&source_skills)? { - let entry = entry?; - let file_type = entry.file_type()?; - if !file_type.is_dir() { - continue; - } + for source_skills_dir in source_skills { + for entry in fs::read_dir(&source_skills_dir)? { + let entry = entry?; + let file_type = entry.file_type()?; + if !file_type.is_dir() { + continue; + } - let target = target_skills.join(entry.file_name()); - if target.exists() { - continue; - } + let target = target_skills.join(entry.file_name()); + if target.exists() { + continue; + } - copy_dir_recursive(&entry.path(), &target, self.source.rewrite_profile())?; - copied_names.push(entry.file_name().to_string_lossy().to_string()); + copy_dir_recursive(&entry.path(), &target, self.source.rewrite_profile())?; + copied_names.push(entry.file_name().to_string_lossy().to_string()); + } } Ok(copied_names) diff --git a/codex-rs/external-agent-migration/src/service_tests/general/detection.rs b/codex-rs/external-agent-migration/src/service_tests/general/detection.rs index 693002670d..1fbd393ec8 100644 --- a/codex-rs/external-agent-migration/src/service_tests/general/detection.rs +++ b/codex-rs/external-agent-migration/src/service_tests/general/detection.rs @@ -69,6 +69,91 @@ async fn detect_home_lists_config_skills_and_agents_md() { assert_eq!(items, expected); } +#[tokio::test] +async fn detect_cursor_home_lists_user_and_managed_skills() { + let root = TempDir::new().expect("create tempdir"); + let external_agent_home = root.path().join(".cursor"); + let codex_home = root.path().join(".codex"); + let user_skills = external_agent_home.join("skills"); + let managed_skills = external_agent_home.join("skills-cursor"); + let target_skills = codex_home + .parent() + .map(|parent| parent.join(".agents").join("skills")) + .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); + fs::create_dir_all(user_skills.join("user-skill")).expect("create user skill"); + fs::create_dir_all(managed_skills.join("managed-skill")).expect("create managed skill"); + let mut service = service_for_paths(external_agent_home, codex_home); + service.source = ExternalAgentSource::Cur; + + let items = service + .detect(ExternalAgentConfigDetectOptions { + include_home: true, + include_memory: false, + cwds: None, + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: format!( + "Migrate skills from {}, {} to {}", + user_skills.display(), + managed_skills.display(), + target_skills.display() + ), + cwd: None, + details: Some(MigrationDetails { + skills: named_migrations(vec![ + "managed-skill".to_string(), + "user-skill".to_string() + ]), + ..Default::default() + }), + }] + ); +} + +#[tokio::test] +async fn detect_cursor_repo_lists_skills_from_skills() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + let codex_home = root.path().join(".codex"); + let source_skills = repo_root.join(".cursor").join("skills"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all(source_skills.join("skill-a")).expect("create repo skill"); + let mut service = service_for_paths(root.path().join(".cursor"), codex_home); + service.source = ExternalAgentSource::Cur; + + let items = service + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + include_memory: false, + cwds: Some(vec![repo_root.clone()]), + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: format!( + "Migrate skills from {} to {}", + source_skills.display(), + repo_root.join(".agents").join("skills").display() + ), + cwd: Some(repo_root), + details: Some(MigrationDetails { + skills: named_migrations(vec!["skill-a".to_string()]), + ..Default::default() + }), + }] + ); +} + #[tokio::test] async fn detect_home_lists_recent_sessions() { let (root, external_agent_home, codex_home) = fixture_paths(); diff --git a/codex-rs/external-agent-migration/src/service_tests/plugins/marketplaces.rs b/codex-rs/external-agent-migration/src/service_tests/plugins/marketplaces.rs index c3ccafe6b2..a8e8699ce5 100644 --- a/codex-rs/external-agent-migration/src/service_tests/plugins/marketplaces.rs +++ b/codex-rs/external-agent-migration/src/service_tests/plugins/marketplaces.rs @@ -710,3 +710,43 @@ fn import_skills_returns_only_new_skill_directory_names() { assert_eq!(copied_names, vec!["skill-b".to_string()]); } + +#[test] +fn import_cursor_skills_reads_user_and_managed_directories() { + let root = TempDir::new().expect("create tempdir"); + let external_agent_home = root.path().join(".cursor"); + let codex_home = root.path().join(".codex"); + let user_skill = external_agent_home.join("skills").join("user-skill"); + let managed_skill = external_agent_home + .join("skills-cursor") + .join("managed-skill"); + let target_skills = codex_home + .parent() + .map(|parent| parent.join(".agents").join("skills")) + .unwrap_or_else(|| PathBuf::from(".agents").join("skills")); + fs::create_dir_all(&user_skill).expect("create user skill"); + fs::create_dir_all(&managed_skill).expect("create managed skill"); + fs::write(user_skill.join("SKILL.md"), "# Imported user skill").expect("write user skill"); + fs::write(managed_skill.join("SKILL.md"), "# Imported managed skill") + .expect("write managed skill"); + let mut service = service_for_paths(external_agent_home, codex_home); + service.source = ExternalAgentSource::Cur; + + let mut copied_names = service.import_skills(/*cwd*/ None).expect("import skills"); + copied_names.sort(); + + assert_eq!( + copied_names, + vec!["managed-skill".to_string(), "user-skill".to_string()] + ); + assert_eq!( + fs::read_to_string(target_skills.join("user-skill").join("SKILL.md")) + .expect("read user skill"), + "# Imported user skill" + ); + assert_eq!( + fs::read_to_string(target_skills.join("managed-skill").join("SKILL.md")) + .expect("read managed skill"), + "# Imported managed skill" + ); +}