Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 116 additions & 1 deletion code-rs/core/src/skills/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut Skil
return;
}

// Follow symlinked directories for user and repo skills.
// System skills are managed by the tool itself, so symlinks are not followed.
let follow_symlinks = matches!(scope, SkillScope::User | SkillScope::Repo);

// Track visited directories to prevent infinite loops from circular symlinks.
let mut visited: HashSet<PathBuf> = HashSet::new();
visited.insert(root.clone());

let mut queue: VecDeque<PathBuf> = VecDeque::from([root]);
while let Some(dir) = queue.pop_front() {
let entries = match fs::read_dir(&dir) {
Expand All @@ -187,11 +195,39 @@ fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut Skil
};

if file_type.is_symlink() {
if !follow_symlinks {
continue;
}

// Resolve the symlink to determine what it points to.
let metadata = match fs::metadata(&path) {
Ok(m) => m,
Err(e) => {
error!("failed to stat skills entry {} (symlink): {e:#}", path.display());
continue;
}
};

if metadata.is_dir() {
// Canonicalize to detect cycles.
if let Ok(resolved) = normalize_path(&path) {
if visited.insert(resolved.clone()) {
queue.push_back(resolved);
}
}
}
// Symlinked files are not followed - only symlinked directories.
continue;
}

if file_type.is_dir() {
queue.push_back(path);
if let Ok(resolved) = normalize_path(&path) {
if visited.insert(resolved.clone()) {
queue.push_back(resolved);
}
} else {
queue.push_back(path);
}
continue;
}

Expand Down Expand Up @@ -282,3 +318,82 @@ fn extract_frontmatter(contents: &str) -> Option<String> {

Some(frontmatter_lines.join("\n"))
}

#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
use tempfile::TempDir;
Comment on lines +324 to +325
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard Unix-only symlink use in tests

These new tests unconditionally import std::os::unix::fs::symlink, which is unavailable on Windows. As a result, cargo test on Windows will fail to compile this module even before running tests. If Windows CI/devs run tests (the repo ships Windows builds), this becomes an immediate test break. Consider adding #[cfg(unix)] to the test module or providing a Windows-specific symlink helper (e.g., std::os::windows::fs::symlink_dir) to keep tests portable.

Useful? React with 👍 / 👎.


fn write_skill(dir: &Path, name: &str, description: &str) -> PathBuf {
let skill_dir = dir.join(name);
fs::create_dir_all(&skill_dir).unwrap();
let skill_file = skill_dir.join("SKILL.md");
fs::write(
&skill_file,
format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n"),
)
.unwrap();
skill_file
}

#[test]
fn follows_symlinked_directory_for_user_scope() {
let temp = TempDir::new().unwrap();
let skills_root = temp.path().join("skills");
fs::create_dir_all(&skills_root).unwrap();

// Create a skill in a separate directory
let shared = TempDir::new().unwrap();
write_skill(shared.path(), "shared-skill", "A shared skill");

// Symlink the shared directory into skills root
symlink(shared.path(), skills_root.join("shared")).unwrap();

let mut outcome = SkillLoadOutcome::default();
discover_skills_under_root(&skills_root, SkillScope::User, &mut outcome);

assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "shared-skill");
}

#[test]
fn ignores_symlinked_directory_for_system_scope() {
let temp = TempDir::new().unwrap();
let skills_root = temp.path().join("skills");
fs::create_dir_all(&skills_root).unwrap();

// Create a skill in a separate directory
let shared = TempDir::new().unwrap();
write_skill(shared.path(), "shared-skill", "A shared skill");

// Symlink the shared directory into skills root
symlink(shared.path(), skills_root.join("shared")).unwrap();

let mut outcome = SkillLoadOutcome::default();
discover_skills_under_root(&skills_root, SkillScope::System, &mut outcome);

assert_eq!(outcome.skills.len(), 0, "System scope should ignore symlinks");
}

#[test]
fn handles_circular_symlink_without_infinite_loop() {
let temp = TempDir::new().unwrap();
let skills_root = temp.path().join("skills");
let cycle_dir = skills_root.join("cycle");
fs::create_dir_all(&cycle_dir).unwrap();

// Create a circular symlink
symlink(&cycle_dir, cycle_dir.join("loop")).unwrap();

// Also add a real skill to verify we still find it
write_skill(&cycle_dir, "real-skill", "A real skill");

let mut outcome = SkillLoadOutcome::default();
discover_skills_under_root(&skills_root, SkillScope::User, &mut outcome);

// Should find the real skill and not infinite loop
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "real-skill");
}
}
2 changes: 1 addition & 1 deletion docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Skills are behind the experimental `skills` feature flag and are enabled by defa

## Where skills live

- Location (v1): `~/.codex/skills/**/SKILL.md` (recursive). Hidden entries and symlinks are skipped. Only files named exactly `SKILL.md` count.
- Location (v1): `~/.code/skills/**/SKILL.md` (recursive, also reads `~/.codex/skills/` for compatibility). Hidden entries are skipped. Symlinked directories are followed for user and repo skills (but not system skills). Only files named exactly `SKILL.md` count.
- Sorting: rendered by name, then path for stability.

## File format
Expand Down