first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,60 +1,111 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::{Path, PathBuf},
|
||||
sync::LazyLock,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ai::skills::{
|
||||
home_skills_path, read_skills, ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS,
|
||||
home_skills_path, parse_skill, provider_parent_directory_for_skills_root, read_skills,
|
||||
ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS,
|
||||
};
|
||||
use anyhow::Error;
|
||||
use repo_metadata::{RepoMetadataModel, RepositoryIdentifier};
|
||||
use walkdir::{DirEntry, WalkDir};
|
||||
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use galaxy_util::remote_path::RemotePath;
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
use galaxyui::AppContext;
|
||||
use regex::Regex;
|
||||
use repo_metadata::{local_model::GetContentsArgs, RepoContent, RepoMetadataModel};
|
||||
|
||||
use crate::galaxy_managed_paths_watcher::galaxy_managed_skill_dirs;
|
||||
|
||||
/// Finds all skill directories in a repository by querying the RepoMetadataModel tree.
|
||||
fn local_or_remote_path_for_repo_path(
|
||||
repo_id: &RepositoryIdentifier,
|
||||
path: &StandardizedPath,
|
||||
) -> LocalOrRemotePath {
|
||||
match repo_id {
|
||||
RepositoryIdentifier::Local(_) => LocalOrRemotePath::Local(path.to_local_path_lossy()),
|
||||
RepositoryIdentifier::Remote(remote) => {
|
||||
LocalOrRemotePath::Remote(RemotePath::new(remote.host_id.clone(), path.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds project skill files from stored standing results.
|
||||
///
|
||||
/// Returns a list of paths to skill directories (e.g., `/repo/.agents/skills/`, `/repo/sub/.claude/skills/`).
|
||||
pub fn find_skill_directories_in_tree(
|
||||
repo_path: &Path,
|
||||
/// Symlinked project skills are resolved while evaluating standing queries on the process that
|
||||
/// owns the repository. This consumer treats those results as authoritative for both local and
|
||||
/// remote repositories; direct filesystem discovery remains confined to metadata-failure fallback.
|
||||
pub(super) fn find_project_skill_files_in_tree(
|
||||
repo_id: &RepositoryIdentifier,
|
||||
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
|
||||
.iter()
|
||||
.map(|p| p.skills_path.as_path())
|
||||
.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 args = GetContentsArgs::default().with_filter(move |content| {
|
||||
let RepoContent::Directory(dir) = content else {
|
||||
return false;
|
||||
};
|
||||
skill_path_suffixes
|
||||
.iter()
|
||||
.any(|suffix| dir.path.ends_with(&suffix.to_string_lossy()))
|
||||
});
|
||||
|
||||
let Some(id) = repo_metadata::RepositoryIdentifier::try_local(repo_path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
) -> Vec<LocalOrRemotePath> {
|
||||
repo_metadata
|
||||
.get_repo_contents(&id, args, ctx)
|
||||
.unwrap_or_default()
|
||||
.standing_query_results(repo_id, ctx)
|
||||
.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(),
|
||||
.flat_map(|results| results.project_skills())
|
||||
.filter(|content| !content.is_directory)
|
||||
.map(|content| local_or_remote_path_for_repo_path(repo_id, &content.path))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Finds local project skill files by discovering provider directories on the filesystem.
|
||||
///
|
||||
/// This is a local-only fallback for repositories whose repo metadata indexing fails. Successful
|
||||
/// local and remote project refreshes should use [`find_project_skill_files_in_tree`] so the
|
||||
/// normal metadata-backed path remains shared.
|
||||
pub(super) fn find_local_project_skill_files_on_filesystem(
|
||||
scan_root: &Path,
|
||||
) -> Vec<LocalOrRemotePath> {
|
||||
let direct_skill_file = scan_root.join("SKILL.md");
|
||||
if is_skill_file(&direct_skill_file) {
|
||||
return vec![LocalOrRemotePath::Local(direct_skill_file)];
|
||||
}
|
||||
|
||||
find_local_provider_directories_on_filesystem(scan_root)
|
||||
.into_iter()
|
||||
.flat_map(|provider_dir| std::fs::read_dir(provider_dir).into_iter().flatten())
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| {
|
||||
let skill_dir = entry.path();
|
||||
if !skill_dir.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let skill_file = skill_dir.join("SKILL.md");
|
||||
skill_file
|
||||
.exists()
|
||||
.then_some(LocalOrRemotePath::Local(skill_file))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_local_provider_directories_on_filesystem(scan_root: &Path) -> Vec<PathBuf> {
|
||||
let mut provider_dirs = Vec::new();
|
||||
let mut entries = WalkDir::new(scan_root).follow_links(false).into_iter();
|
||||
while let Some(entry) = entries.next() {
|
||||
let Ok(entry) = entry else {
|
||||
continue;
|
||||
};
|
||||
if is_ignored_fallback_scan_entry(&entry) {
|
||||
if entry.file_type().is_dir() {
|
||||
entries.skip_current_dir();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if entry.file_type().is_dir() && is_project_provider_path(entry.path()) {
|
||||
provider_dirs.push(entry.into_path());
|
||||
entries.skip_current_dir();
|
||||
}
|
||||
}
|
||||
provider_dirs.sort();
|
||||
provider_dirs
|
||||
}
|
||||
|
||||
fn is_ignored_fallback_scan_entry(entry: &DirEntry) -> bool {
|
||||
entry.file_name().to_str() == Some(".git")
|
||||
}
|
||||
|
||||
fn is_project_provider_path(path: &Path) -> bool {
|
||||
SKILL_PROVIDER_DEFINITIONS
|
||||
.iter()
|
||||
.any(|provider| path.ends_with(&provider.skills_path))
|
||||
}
|
||||
/// Reads all skills from the given skill directories.
|
||||
pub fn read_skills_from_directories(
|
||||
skill_dirs: impl IntoIterator<Item = PathBuf>,
|
||||
@@ -64,60 +115,51 @@ pub fn read_skills_from_directories(
|
||||
.flat_map(|dir| read_skills(&dir))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn is_skill_file(path: &Path) -> bool {
|
||||
extract_skill_parent_directory(path).is_ok()
|
||||
/// Reads all skills from the given concrete skill files.
|
||||
pub fn read_skills_from_files(skill_files: impl IntoIterator<Item = PathBuf>) -> Vec<ParsedSkill> {
|
||||
skill_files
|
||||
.into_iter()
|
||||
.filter_map(|path| parse_skill(&path).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
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()
|
||||
});
|
||||
pub fn is_skill_file(path: &Path) -> bool {
|
||||
extract_skill_parent_directory(&LocalOrRemotePath::Local(path.to_path_buf())).is_ok()
|
||||
}
|
||||
|
||||
// Pattern: {prefix}/{provider_path}/{skill-name}/SKILL.md
|
||||
// where provider_path is 2 parts (e.g., ".agents/skills") and skill-name is 1 part
|
||||
#[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$")
|
||||
.expect("Failed to compile skill file pattern")
|
||||
});
|
||||
|
||||
pub fn extract_skill_parent_directory(path: &Path) -> Result<PathBuf, Error> {
|
||||
pub fn extract_skill_parent_directory(
|
||||
path: &LocalOrRemotePath,
|
||||
) -> Result<LocalOrRemotePath, Error> {
|
||||
let is_warp_home_skill = path
|
||||
.file_name()
|
||||
.to_local_path()
|
||||
.and_then(Path::file_name)
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name == "SKILL.md")
|
||||
&& path
|
||||
.parent()
|
||||
.to_local_path()
|
||||
.and_then(Path::parent)
|
||||
.and_then(Path::parent)
|
||||
.is_some_and(|parent| galaxy_managed_skill_dirs().iter().any(|dir| parent == dir));
|
||||
if is_warp_home_skill {
|
||||
return dirs::home_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("Home directory not available for {}", path.display()));
|
||||
.map(LocalOrRemotePath::Local)
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("Home directory not available for {}", path.display_path())
|
||||
});
|
||||
}
|
||||
let path_str = path.to_string_lossy();
|
||||
|
||||
if let Some(captures) = SKILL_FILE_PATTERN.captures(&path_str) {
|
||||
if let Some(provider_path) = captures.get(2) {
|
||||
if SKILL_PROVIDER_PATHS.contains(provider_path.as_str()) {
|
||||
if let Some(parent_directory) = captures.get(1) {
|
||||
return Ok(PathBuf::from(parent_directory.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if path.file_name() != Some("SKILL.md") {
|
||||
return Err(anyhow::anyhow!("Not a skill path: {}", path.display_path()));
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("Not a skill path: {}", path.display()))
|
||||
let Some(skill_dir) = path.parent() else {
|
||||
return Err(anyhow::anyhow!("Not a skill path: {}", path.display_path()));
|
||||
};
|
||||
let Some(skills_root) = skill_dir.parent() else {
|
||||
return Err(anyhow::anyhow!("Not a skill path: {}", path.display_path()));
|
||||
};
|
||||
|
||||
provider_parent_directory_for_skills_root(&skills_root)
|
||||
.ok_or_else(|| anyhow::anyhow!("Not a skill path: {}", path.display_path()))
|
||||
}
|
||||
|
||||
/// Check if this path is a skill directory under a home directory provider path
|
||||
|
||||
Reference in New Issue
Block a user