-
Notifications
You must be signed in to change notification settings - Fork 3.3k
fix: skill discovery in subdirectories #9583
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
3e69dfa
73cddd0
cdeaa5b
113ab0f
9919504
2b820ca
01c6a99
6c88e14
47dc8b6
c3ca38b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,45 +14,143 @@ use warpui::AppContext; | |
|
|
||
| use crate::warp_managed_paths_watcher::warp_managed_skill_dirs; | ||
|
|
||
| /// Finds all skill directories in a repository by querying the RepoMetadataModel tree. | ||
| /// Max directory depth walked below a lazy node when searching for provider skill dirs. | ||
| const MAX_LAZY_WALK_DEPTH: usize = 3; | ||
|
|
||
| /// Walks `dir` up to `MAX_LAZY_WALK_DEPTH` levels deep, probing each level for | ||
| /// provider skill directories. `.git` entries are pruned to avoid crossing nested | ||
| /// repository boundaries. | ||
| fn probe_lazy_subtree( | ||
| dir: &Path, | ||
| depth: usize, | ||
| result_set: &mut HashSet<PathBuf>, | ||
| result: &mut Vec<PathBuf>, | ||
| ) { | ||
| for provider in SKILL_PROVIDER_DEFINITIONS.iter() { | ||
| let skills_path = dir.join(&provider.skills_path); | ||
| if !result_set.contains(&skills_path) && skills_path.is_dir() { | ||
| result_set.insert(skills_path.clone()); | ||
| result.push(skills_path); | ||
| } | ||
| } | ||
|
|
||
| if depth >= MAX_LAZY_WALK_DEPTH { | ||
| return; | ||
| } | ||
|
|
||
| let Ok(entries) = std::fs::read_dir(dir) else { | ||
| return; | ||
| }; | ||
| for entry in entries.flatten() { | ||
| let path = entry.path(); | ||
| if !path.is_dir() { | ||
| continue; | ||
| } | ||
| // Prune .git to avoid crossing nested repository boundaries. | ||
| if path.file_name().and_then(|n| n.to_str()) == Some(".git") { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| continue; | ||
| } | ||
| probe_lazy_subtree(&path, depth + 1, result_set, result); | ||
| } | ||
| } | ||
|
|
||
| /// Returns all skill directories in the repo tree (e.g. `/repo/.agents/skills/`). | ||
| /// | ||
| /// Uses two passes to handle gitignored provider directories: | ||
| /// | ||
| /// - **Pass 1:** collect fully-loaded dirs ending with a known provider skills path. | ||
| /// - **Pass 2:** collect lazy-loaded dirs (`loaded: false`) and probe with `is_dir()`: | ||
| /// - *(a)* lazy dir is a provider root (`.agents`, `.claude`, …) → probe `{dir}/skills`. | ||
| /// - *(b)* lazy dir is an ancestor of a provider root → walk up to | ||
| /// `MAX_LAZY_WALK_DEPTH` levels, probing for provider paths at each level. | ||
| /// | ||
| /// Returns a list of paths to skill directories (e.g., `/repo/.agents/skills/`, `/repo/sub/.claude/skills/`). | ||
| /// Pass 2 scope is bounded by the tree: lazy dirs only appear as children of indexed | ||
| /// parents, so the walk starts from directories the indexer already encountered. | ||
| pub fn find_skill_directories_in_tree( | ||
| repo_path: &Path, | ||
| repo_metadata: &RepoMetadataModel, | ||
| ctx: &AppContext, | ||
| ) -> Vec<PathBuf> { | ||
| // Collect provider skills paths (e.g., ".agents/skills", ".claude/skills") | ||
| let skill_path_suffixes: Vec<&Path> = SKILL_PROVIDER_DEFINITIONS | ||
| let Some(id) = repo_metadata::RepositoryIdentifier::try_local(repo_path) else { | ||
| return Vec::new(); | ||
| }; | ||
|
|
||
| // Provider skills paths (e.g. ".agents/skills") and root names (e.g. ".agents"). | ||
| let skill_path_suffixes: Vec<String> = SKILL_PROVIDER_DEFINITIONS | ||
| .iter() | ||
| .map(|p| p.skills_path.to_string_lossy().into_owned()) | ||
| .collect(); | ||
|
|
||
| let provider_root_names: HashSet<String> = SKILL_PROVIDER_DEFINITIONS | ||
| .iter() | ||
| .map(|p| p.skills_path.as_path()) | ||
| .filter_map(|p| { | ||
| p.skills_path | ||
| .parent() | ||
| .and_then(Path::file_name) | ||
| .and_then(|n| n.to_str()) | ||
| .map(str::to_owned) | ||
| }) | ||
| .collect(); | ||
|
|
||
| // Filter during traversal: only collect directories that end with a skill provider path. | ||
| // The filter rejects files and non-matching directories, avoiding intermediate allocations. | ||
| // Pass 1: loaded dirs whose path ends with a known provider skills suffix. | ||
| let suffixes_1 = skill_path_suffixes.clone(); | ||
| let args = GetContentsArgs::default().with_filter(move |content| { | ||
| let RepoContent::Directory(dir) = content else { | ||
| return false; | ||
| }; | ||
| skill_path_suffixes | ||
| suffixes_1 | ||
| .iter() | ||
| .any(|suffix| dir.path.ends_with(&suffix.to_string_lossy())) | ||
| .any(|suffix| dir.path.ends_with(suffix.as_str())) | ||
| }); | ||
|
|
||
| let Some(id) = repo_metadata::RepositoryIdentifier::try_local(repo_path) else { | ||
| return Vec::new(); | ||
| }; | ||
| repo_metadata | ||
| let mut result: Vec<PathBuf> = repo_metadata | ||
| .get_repo_contents(&id, args, ctx) | ||
| .unwrap_or_default() | ||
| .into_iter() | ||
| // Only directories should reach this iterator due to the GetContentsArgs::filter. | ||
| // Keep the File arm for exhaustive matching in case RepoContent grows new variants. | ||
| .map(|content| match content { | ||
| RepoContent::Directory(dir) => dir.path.to_local_path_lossy(), | ||
| RepoContent::File(f) => f.path.to_local_path_lossy(), | ||
| }) | ||
| .collect() | ||
| .collect(); | ||
|
|
||
| // Pass 2: probe lazy-loaded (gitignored) dirs. | ||
| let mut result_set: HashSet<PathBuf> = result.iter().cloned().collect(); | ||
| let args_lazy = GetContentsArgs::default() | ||
| .include_ignored() | ||
| .with_filter(move |content| { | ||
| let RepoContent::Directory(dir) = content else { | ||
| return false; | ||
| }; | ||
| !dir.loaded | ||
|
rajgandhi1 marked this conversation as resolved.
|
||
| }); | ||
|
|
||
| let lazy_dirs: Vec<PathBuf> = repo_metadata | ||
| .get_repo_contents(&id, args_lazy, ctx) | ||
| .unwrap_or_default() | ||
| .into_iter() | ||
| .map(|content| match content { | ||
| RepoContent::Directory(dir) => dir.path.to_local_path_lossy(), | ||
| RepoContent::File(f) => f.path.to_local_path_lossy(), | ||
| }) | ||
| .collect(); | ||
|
|
||
| for lazy_dir in lazy_dirs { | ||
| let dir_name = lazy_dir.file_name().and_then(|n| n.to_str()).unwrap_or(""); | ||
| if provider_root_names.contains(dir_name) { | ||
| // Case (a): lazy dir is a provider root — probe {dir}/skills. | ||
| let skills_path = lazy_dir.join("skills"); | ||
| if !result_set.contains(&skills_path) && skills_path.is_dir() { | ||
| result_set.insert(skills_path.clone()); | ||
| result.push(skills_path); | ||
| } | ||
| } else { | ||
| // Case (b): lazy dir may be an ancestor of a provider root — walk | ||
| // its subtree up to MAX_LAZY_WALK_DEPTH levels. | ||
| probe_lazy_subtree(&lazy_dir, 0, &mut result_set, &mut result); | ||
| } | ||
| } | ||
|
|
||
| result | ||
| } | ||
|
|
||
| /// Reads all skills from the given skill directories. | ||
|
|
@@ -70,22 +168,19 @@ pub fn is_skill_file(path: &Path) -> bool { | |
| } | ||
|
|
||
| static SKILL_PROVIDER_PATHS: LazyLock<HashSet<String>> = LazyLock::new(|| { | ||
| // Collect the skill provider paths from the definitions | ||
| SKILL_PROVIDER_DEFINITIONS | ||
| .iter() | ||
| .map(|p| p.skills_path.to_string_lossy().to_string()) | ||
| .collect() | ||
| }); | ||
|
|
||
| // Pattern: {prefix}/{provider_path}/{skill-name}/SKILL.md | ||
| // where provider_path is 2 parts (e.g., ".agents/skills") and skill-name is 1 part | ||
| // Matches {prefix}/{provider_path}/{skill-name}/SKILL.md (provider_path is 2 components). | ||
| #[cfg(not(target_os = "windows"))] | ||
| static SKILL_FILE_PATTERN: LazyLock<Regex> = LazyLock::new(|| { | ||
| Regex::new(r"(.+)/([^/]+/[^/]+)/[^/]+/SKILL\.md$") | ||
| .expect("Failed to compile skill file pattern") | ||
| }); | ||
|
|
||
| // On windows, the path separator is \ | ||
| #[cfg(target_os = "windows")] | ||
| static SKILL_FILE_PATTERN: LazyLock<Regex> = LazyLock::new(|| { | ||
| Regex::new(r"(.+)\\([^\\]+\\[^\\]+)\\[^\\]+\\SKILL\.md$") | ||
|
|
@@ -120,19 +215,13 @@ pub fn extract_skill_parent_directory(path: &Path) -> Result<PathBuf, Error> { | |
| Err(anyhow::anyhow!("Not a skill path: {}", path.display())) | ||
| } | ||
|
|
||
| /// Check if this path is a skill directory under a home directory provider path | ||
| /// E.g. ~/.agents/skills/skill-name | ||
| /// Returns true if `path` is a skill directory under a home provider path | ||
| /// (e.g. `~/.agents/skills/skill-name`). | ||
| pub fn is_home_skill_directory(path: &Path) -> bool { | ||
| let parent_directory = path.parent(); | ||
| if let Some(parent_directory) = parent_directory { | ||
| is_home_provider_path(parent_directory) | ||
| } else { | ||
| false | ||
| } | ||
| path.parent().is_some_and(is_home_provider_path) | ||
| } | ||
|
|
||
| /// Check if this path is a home directory provider path | ||
| /// E.g. ~/.agents/skills | ||
| /// Returns true if `path` is a home provider skills path (e.g. `~/.agents/skills`). | ||
| pub fn is_home_provider_path(path: &Path) -> bool { | ||
| SKILL_PROVIDER_DEFINITIONS.iter().any(|provider| { | ||
| if provider.provider == SkillProvider::Warp { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
read_dirrecursion runs fromfind_skill_directories_in_tree, which is called on model-context repo update paths; probing large ignored directories such asnode_modulesortargetcan block the app. Move the filesystem probing to a background task or keep it outside the model read path.