Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
127 changes: 126 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,92 @@ fn extract_frontmatter(contents: &str) -> Option<String> {

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

#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;

/// Cross-platform directory symlink helper.
#[cfg(unix)]
fn symlink_dir(src: &Path, dst: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(src, dst)
}

#[cfg(windows)]
fn symlink_dir(src: &Path, dst: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_dir(src, dst)
}

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_dir(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_dir(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_dir(&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");
}
}
40 changes: 36 additions & 4 deletions code-rs/tui/src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,16 @@ pub fn stdout_ready_for_writes() -> bool {
}

fn should_enable_alternate_scroll_mode() -> bool {
// macOS Terminal hijacks scrolling when 1007h is set without also enabling
// mouse reporting, so skip the escape in that environment.
!matches!(env::var("TERM_PROGRAM"), Ok(value) if value.eq_ignore_ascii_case("Apple_Terminal"))
// Hard overrides first.
if env::var("CODE_DISABLE_ALT_SCROLL").map(|v| v == "1").unwrap_or(false) {
return false;
}
if env::var("CODE_ENABLE_ALT_SCROLL").map(|v| v == "1").unwrap_or(false) {
return true;
}

// Default off to preserve native terminal scrollback + selection behavior.
false
}

/// Clear the current screen (normal buffer) with the theme background and reset cursor.
Expand Down Expand Up @@ -428,7 +435,7 @@ pub(crate) fn should_enable_keyboard_enhancement() -> bool {

#[cfg(test)]
mod tests {
use super::should_enable_keyboard_enhancement;
use super::{should_enable_alternate_scroll_mode, should_enable_keyboard_enhancement};

#[test]
fn keyboard_enhancement_respects_env_overrides() {
Expand Down Expand Up @@ -458,6 +465,31 @@ mod tests {
restore_env("CODE_DISABLE_KBD_ENHANCEMENT", prev_disable);
}

#[test]
fn alternate_scroll_mode_respects_env_overrides() {
let prev_enable = std::env::var("CODE_ENABLE_ALT_SCROLL").ok();
let prev_disable = std::env::var("CODE_DISABLE_ALT_SCROLL").ok();

unsafe {
std::env::remove_var("CODE_ENABLE_ALT_SCROLL");
std::env::remove_var("CODE_DISABLE_ALT_SCROLL");
}
assert!(!should_enable_alternate_scroll_mode());

unsafe {
std::env::set_var("CODE_ENABLE_ALT_SCROLL", "1");
}
assert!(should_enable_alternate_scroll_mode());

unsafe {
std::env::set_var("CODE_DISABLE_ALT_SCROLL", "1");
}
assert!(!should_enable_alternate_scroll_mode());

restore_env("CODE_ENABLE_ALT_SCROLL", prev_enable);
restore_env("CODE_DISABLE_ALT_SCROLL", prev_disable);
}

fn restore_env(key: &str, value: Option<String>) {
match value {
Some(value) => unsafe { std::env::set_var(key, value) },
Expand Down
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