-
Notifications
You must be signed in to change notification settings - Fork 3.4k
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 all 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,174 @@ 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; | ||
|
|
||
| /// Result of the fast (in-memory) phase of skill directory discovery. | ||
| pub struct SkillDirectoryScan { | ||
| /// Skill directories found by tree traversal — safe to use on the model path. | ||
| pub found: Vec<PathBuf>, | ||
| /// Lazy-loaded dirs whose subtrees need recursive filesystem probing (Case b). | ||
| /// Pass these to [`probe_lazy_subtrees`] off the model path. | ||
| pub lazy_dirs: Vec<PathBuf>, | ||
| } | ||
|
|
||
| /// Fast, in-memory phase: queries the repo tree and returns discovered skill dirs | ||
| /// plus any lazy ancestor dirs that still need probing. | ||
| /// | ||
| /// Returns a list of paths to skill directories (e.g., `/repo/.agents/skills/`, `/repo/sub/.claude/skills/`). | ||
| pub fn find_skill_directories_in_tree( | ||
| /// - **Pass 1:** fully-loaded dirs whose path ends with a known provider skills suffix. | ||
| /// - **Pass 2 Case (a):** lazy dirs named like a provider root (`.agents`, `.claude`, …) | ||
| /// — probed inline with a single `is_dir()` since they have a known structure. | ||
| /// - **Pass 2 Case (b):** all other lazy dirs are returned in `lazy_dirs` for the | ||
| /// caller to probe off the model path via [`probe_lazy_subtrees`]. | ||
| pub fn scan_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 | ||
| ) -> SkillDirectoryScan { | ||
| let Some(id) = repo_metadata::RepositoryIdentifier::try_local(repo_path) else { | ||
| return SkillDirectoryScan { | ||
| found: Vec::new(), | ||
| lazy_dirs: Vec::new(), | ||
| }; | ||
| }; | ||
|
|
||
| let skill_path_suffixes: Vec<String> = SKILL_PROVIDER_DEFINITIONS | ||
| .iter() | ||
| .map(|p| p.skills_path.as_path()) | ||
| .map(|p| p.skills_path.to_string_lossy().into_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. | ||
| let provider_root_names: HashSet<String> = SKILL_PROVIDER_DEFINITIONS | ||
| .iter() | ||
| .filter_map(|p| { | ||
| p.skills_path | ||
| .parent() | ||
| .and_then(Path::file_name) | ||
| .and_then(|n| n.to_str()) | ||
| .map(str::to_owned) | ||
| }) | ||
| .collect(); | ||
|
|
||
| // 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 found: 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: collect all lazy-loaded dirs. | ||
| let args_lazy = GetContentsArgs::default() | ||
| .include_ignored() | ||
| .with_filter(move |content| { | ||
| let RepoContent::Directory(dir) = content else { | ||
| return false; | ||
| }; | ||
| !dir.loaded | ||
| }); | ||
|
|
||
| let all_lazy: 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(); | ||
|
|
||
| let mut found_set: HashSet<PathBuf> = found.iter().cloned().collect(); | ||
| let mut lazy_dirs: Vec<PathBuf> = Vec::new(); | ||
|
|
||
| for lazy_dir in all_lazy { | ||
| 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 — single cheap probe for {dir}/skills. | ||
| let skills_path = lazy_dir.join("skills"); | ||
| if !found_set.contains(&skills_path) && skills_path.is_dir() { | ||
| found_set.insert(skills_path.clone()); | ||
| found.push(skills_path); | ||
| } | ||
| } else { | ||
| // Case (b): lazy dir may be an ancestor — defer to probe_lazy_subtrees. | ||
| lazy_dirs.push(lazy_dir); | ||
| } | ||
| } | ||
|
|
||
| SkillDirectoryScan { found, lazy_dirs } | ||
| } | ||
|
|
||
| /// Walks each dir up to `MAX_LAZY_WALK_DEPTH` levels, probing for provider skill dirs. | ||
| /// Skips subdirectories that contain a `.git` entry (nested repository roots). | ||
| /// Intended to run off the model path (e.g. inside `ctx.spawn`). | ||
| pub fn probe_lazy_subtrees(lazy_dirs: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> { | ||
| let mut result = Vec::new(); | ||
| let mut result_set = HashSet::new(); | ||
| for dir in lazy_dirs { | ||
| probe_subtree(&dir, 0, &mut result_set, &mut result); | ||
| } | ||
| result | ||
| } | ||
|
|
||
| fn probe_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() { | ||
|
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. 🚨 [CRITICAL] |
||
| 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() { | ||
|
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. 🚨 [CRITICAL] This |
||
| continue; | ||
| } | ||
| // Skip nested repository roots — their skills belong to their own scan. | ||
| if path.join(".git").exists() { | ||
| continue; | ||
| } | ||
| probe_subtree(&path, depth + 1, result_set, result); | ||
| } | ||
| } | ||
|
|
||
| /// Convenience wrapper that runs both phases synchronously. | ||
| /// Use [`scan_skill_directories_in_tree`] + [`probe_lazy_subtrees`] when calling | ||
| /// from a model context so the filesystem walk runs off the model path. | ||
| pub fn find_skill_directories_in_tree( | ||
| repo_path: &Path, | ||
| repo_metadata: &RepoMetadataModel, | ||
| ctx: &AppContext, | ||
| ) -> Vec<PathBuf> { | ||
| let scan = scan_skill_directories_in_tree(repo_path, repo_metadata, ctx); | ||
| let mut found = scan.found; | ||
| found.extend(probe_lazy_subtrees(scan.lazy_dirs)); | ||
| found | ||
| } | ||
|
|
||
| /// Reads all skills from the given skill directories. | ||
|
|
@@ -70,22 +199,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 +246,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.
probe_lazy_subtreesenters each lazy root before applying the.gitguard, so a lazy submodule or nested repository root with.agents/skillsis registered by the parent repo scan; skip roots containing.gitbefore callingprobe_subtree.