-
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 6 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 |
|---|---|---|
|
|
@@ -17,32 +17,66 @@ use crate::warp_managed_paths_watcher::warp_managed_skill_dirs; | |
| /// Finds all skill directories in a repository by querying the RepoMetadataModel tree. | ||
| /// | ||
| /// Returns a list of paths to skill directories (e.g., `/repo/.agents/skills/`, `/repo/sub/.claude/skills/`). | ||
| /// | ||
| /// Two passes are used to handle gitignored provider directories: | ||
| /// | ||
| /// **Pass 1 — loaded skill dirs:** Standard tree traversal collecting directories that | ||
| /// end with a known provider skills path (e.g. `.agents/skills`). Gitignored directories | ||
| /// are skipped here because they are lazy-loaded with empty children in the tree. | ||
| /// | ||
| /// **Pass 2 — lazy-loaded provider roots:** Traversal with `include_ignored: true` to | ||
| /// find directories that are lazy-loaded (`loaded: false`) *and* are named exactly like a | ||
| /// provider root (`.agents`, `.claude`, …). When such a directory is found, a single | ||
| /// `is_dir()` check is performed for `{provider_dir}/skills`. | ||
| /// | ||
| /// Probing is deliberately restricted to lazy dirs whose name matches a known provider | ||
| /// root. This keeps the trust boundary tight: a lazy dir only enters the tree if its | ||
| /// parent is indexed, so only provider dirs that live directly inside an already-tracked | ||
| /// directory are candidates. Arbitrary dependency or build-artefact subtrees (e.g. | ||
| /// `node_modules/`, `vendor/`, `third_party/`) are never reached because their parent | ||
| /// being lazy-loaded means their children are absent from the tree entirely. | ||
| 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(); | ||
| }; | ||
|
|
||
| // Collect provider skills paths (e.g. ".agents/skills", ".claude/skills") and the | ||
| // corresponding provider root names (e.g. ".agents", ".claude") for the second pass. | ||
| 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(); | ||
|
|
||
| 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: find fully-loaded skill directories ─────────────────────────── | ||
| // | ||
| // 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 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() | ||
|
|
@@ -52,7 +86,57 @@ pub fn find_skill_directories_in_tree( | |
| RepoContent::Directory(dir) => dir.path.to_local_path_lossy(), | ||
| RepoContent::File(f) => f.path.to_local_path_lossy(), | ||
| }) | ||
| .collect() | ||
| .collect(); | ||
|
|
||
| // ── Pass 2: check lazy-loaded provider root directories ─────────────────── | ||
| // | ||
| // Gitignored directories appear in the tree with `loaded: false` and no children. | ||
| // Only dirs whose name matches a known provider root (e.g. `.agents`, `.claude`) | ||
| // are collected here. For each such dir a single `is_dir()` probe is made for the | ||
| // `skills` subdirectory. | ||
| // | ||
| // Probing is restricted to provider-root-named dirs: any other lazy dir (e.g. | ||
| // `node_modules/`, `vendor/`, `third_party/`) is silently skipped. When a broader | ||
| // lazy dir contains a provider root (e.g. `ignored-parent/.agents/`), `.agents/` | ||
| // itself still appears in the tree as a child because only its *own* contents are | ||
| // withheld, so Case (a) still applies. | ||
| let provider_root_names_lazy = provider_root_names.clone(); | ||
| let args_lazy = GetContentsArgs::default() | ||
| .include_ignored() | ||
| .with_filter(move |content| { | ||
| let RepoContent::Directory(dir) = content else { | ||
| return false; | ||
| }; | ||
| if !dir.loaded { | ||
| return dir | ||
| .path | ||
| .file_name() | ||
| .is_some_and(|name| provider_root_names_lazy.contains(name)); | ||
| } | ||
| false | ||
| }); | ||
|
|
||
| let mut result_set: HashSet<PathBuf> = result.iter().cloned().collect(); | ||
|
|
||
| let lazy_provider_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 provider_dir in lazy_provider_dirs { | ||
| let skills_path = provider_dir.join("skills"); | ||
| 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); | ||
| } | ||
| } | ||
|
|
||
| result | ||
| } | ||
|
|
||
| /// Reads all skills from the given skill directories. | ||
|
|
||
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.
sub-project/), because.agents/is not present as a child to probe; either probe provider paths under ignored parent dirs or add the filesystem walk described in the PR body.