diff --git a/code-rs/core/src/skills/loader.rs b/code-rs/core/src/skills/loader.rs index 896d9402b5f..84ab25ee5cf 100644 --- a/code-rs/core/src/skills/loader.rs +++ b/code-rs/core/src/skills/loader.rs @@ -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 = HashSet::new(); + visited.insert(root.clone()); + let mut queue: VecDeque = VecDeque::from([root]); while let Some(dir) = queue.pop_front() { let entries = match fs::read_dir(&dir) { @@ -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; } @@ -282,3 +318,92 @@ fn extract_frontmatter(contents: &str) -> Option { 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"); + } +} diff --git a/code-rs/tui/src/tui.rs b/code-rs/tui/src/tui.rs index d8ed7386b39..4fa7af853fb 100644 --- a/code-rs/tui/src/tui.rs +++ b/code-rs/tui/src/tui.rs @@ -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. @@ -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() { @@ -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) { match value { Some(value) => unsafe { std::env::set_var(key, value) }, diff --git a/docs/skills.md b/docs/skills.md index 29384db4d11..d1ab271fda9 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -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