Migrate Cursor-managed skills into Codex (#36361)

## What changed

- Discover and import home-level Cursor skills from both `skills` and
  `skills-cursor`.
- Keep repository-level Cursor migration scoped to `skills`.
- Deduplicate skill names when reporting migration candidates from multiple
  source directories.

## Testing

- Add coverage for detecting and importing user and Cursor-managed skills.
- Verify repository detection continues to use the `skills` directory.

GitOrigin-RevId: 7b5e96a0d9c5ca14c61230e336728f2e4930a3b0
This commit is contained in:
charlesgong-openai
2026-07-31 16:40:53 +00:00
committed by copyberry
parent 385fe95ce1
commit da2c7ca8d1
5 changed files with 174 additions and 22 deletions
@@ -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::<Vec<_>>();
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(),
@@ -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,
@@ -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::<Vec<_>>();
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)
@@ -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();
@@ -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"
);
}