Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
use std::path::Path;
|
||||
|
||||
use ai::skills::{ParsedSkill, SkillProvider, SkillReference};
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::ai::skills::SkillDescriptor;
|
||||
|
||||
pub struct SkillManager {}
|
||||
|
||||
impl SkillManager {
|
||||
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
pub fn get_skills_for_working_directory(
|
||||
&self,
|
||||
_working_directory: Option<&Path>,
|
||||
_ctx: &AppContext,
|
||||
) -> Vec<SkillDescriptor> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub fn skill_by_path(&self, _skill_path: &Path) -> Option<&ParsedSkill> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn reference_for_skill_path(&self, skill_path: &Path) -> SkillReference {
|
||||
SkillReference::Path(skill_path.to_path_buf())
|
||||
}
|
||||
|
||||
pub fn skill_by_reference(&self, _reference: &SkillReference) -> Option<&ParsedSkill> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn active_bundled_skill(&self, _id: &str, _ctx: &AppContext) -> Option<&ParsedSkill> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn skill_exists_for_any_provider(
|
||||
&self,
|
||||
_skill: &SkillDescriptor,
|
||||
_providers: &[SkillProvider],
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn best_supported_provider(
|
||||
&self,
|
||||
skill: &SkillDescriptor,
|
||||
_supported_providers: &[SkillProvider],
|
||||
) -> SkillProvider {
|
||||
skill.provider
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SkillManager {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for SkillManager {}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod subscribers;
|
||||
|
||||
mod skill_watcher;
|
||||
pub use skill_watcher::{SkillWatcher, SkillWatcherEvent};
|
||||
|
||||
mod utils;
|
||||
pub use utils::extract_skill_parent_directory;
|
||||
@@ -0,0 +1,880 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use super::{
|
||||
subscribers::{
|
||||
HomeSkillSubscriber, ProjectSkillSubscriber, SkillRepositoryMessage, SymlinkSkillSubscriber,
|
||||
},
|
||||
utils::{
|
||||
find_skill_directories_in_tree, is_home_provider_path, is_home_skill_directory,
|
||||
is_skill_file, read_skills_from_directories,
|
||||
},
|
||||
};
|
||||
use watcher::{BulkFilesystemWatcherEvent, HomeDirectoryWatcher, HomeDirectoryWatcherEvent};
|
||||
|
||||
use crate::server::datetime_ext::DateTimeExt;
|
||||
use crate::warp_managed_paths_watcher::{
|
||||
filter_repository_update_by_prefix, warp_managed_skill_dirs, WarpManagedPathsWatcher,
|
||||
WarpManagedPathsWatcherEvent,
|
||||
};
|
||||
use ai::skills::{
|
||||
home_skills_path, parse_skill, ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS,
|
||||
};
|
||||
use async_channel::Sender;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use repo_metadata::{
|
||||
repositories::{DetectedRepositories, RepoDetectionSource},
|
||||
repository::{Repository, SubscriberId},
|
||||
DirectoryWatcher, RepoMetadataModel, RepositoryUpdate,
|
||||
};
|
||||
use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum SkillWatcherEvent {
|
||||
SkillsAdded { skills: Vec<ParsedSkill> },
|
||||
SkillsDeleted { paths: Vec<PathBuf> },
|
||||
}
|
||||
|
||||
// When a new directory is detected by file watchers, we queue it to be scanned for skills later.
|
||||
// These are processed when the file tree is updated.
|
||||
// If a directory is left unprocessed for too long, we will drop it.
|
||||
#[derive(Clone)]
|
||||
pub struct QueuedProjectDirectoryCreation {
|
||||
pub path: PathBuf,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct SkillWatcher {
|
||||
// Channel for sending repository messages from subscribers.
|
||||
repository_message_tx: Sender<SkillRepositoryMessage>,
|
||||
/// Repos we've registered file watchers for (to prevent duplicate subscriptions).
|
||||
watched_repos: HashSet<PathBuf>,
|
||||
queued_project_directory_creations: Vec<QueuedProjectDirectoryCreation>,
|
||||
watcher_event_tx: Sender<SkillWatcherEvent>,
|
||||
/// Tracks watchers on home provider directories (e.g. ~/.agents, ~/.claude) so they
|
||||
/// can be cleaned up when the directory is deleted.
|
||||
home_provider_watchers: HashMap<PathBuf, (ModelHandle<Repository>, SubscriberId)>,
|
||||
/// Maps canonical (resolved) SKILL.md paths → set of original symlink-based paths.
|
||||
/// Multiple symlinks can resolve to the same canonical file, so we track all of them.
|
||||
/// Used to detect changes to the real files behind symlinked skill directories
|
||||
/// on platforms where the OS-level watcher (e.g. FSEvents on macOS) does not
|
||||
/// follow symlinks.
|
||||
symlink_canonical_to_originals: HashMap<PathBuf, HashSet<PathBuf>>,
|
||||
/// Watchers for resolved symlink target directories, keyed by canonical
|
||||
/// parent directory. Used both as a dedup guard (skip if already watching)
|
||||
/// and to hold the subscriber info for error rollback.
|
||||
symlink_target_watchers: HashMap<PathBuf, (ModelHandle<Repository>, SubscriberId)>,
|
||||
}
|
||||
|
||||
impl SkillWatcher {
|
||||
/// Synchronously reads skills from the given repo paths.
|
||||
/// Requires file trees to already be built (i.e. `RepositoryUpdated` has fired).
|
||||
/// Returns the parsed skills; the caller is responsible for feeding them into
|
||||
/// `SkillManager::handle_skills_added`.
|
||||
pub fn read_skills_for_repos(repo_paths: &[PathBuf], ctx: &AppContext) -> Vec<ParsedSkill> {
|
||||
let repo_metadata = RepoMetadataModel::as_ref(ctx);
|
||||
let skill_dirs: Vec<PathBuf> = repo_paths
|
||||
.iter()
|
||||
.flat_map(|repo_path| find_skill_directories_in_tree(repo_path, repo_metadata, ctx))
|
||||
.collect();
|
||||
|
||||
read_skills_from_directories(skill_dirs)
|
||||
}
|
||||
|
||||
pub fn new(ctx: &mut ModelContext<Self>, watcher_event_tx: Sender<SkillWatcherEvent>) -> Self {
|
||||
Self::new_internal(ctx, watcher_event_tx, dirs::home_dir())
|
||||
}
|
||||
|
||||
/// Test-only constructor that skips home-directory watching so tests are not
|
||||
/// polluted by real skills present on the developer's machine.
|
||||
#[cfg(test)]
|
||||
pub fn new_for_testing(
|
||||
ctx: &mut ModelContext<Self>,
|
||||
watcher_event_tx: Sender<SkillWatcherEvent>,
|
||||
) -> Self {
|
||||
Self::new_internal(ctx, watcher_event_tx, None)
|
||||
}
|
||||
|
||||
fn new_internal(
|
||||
ctx: &mut ModelContext<Self>,
|
||||
watcher_event_tx: Sender<SkillWatcherEvent>,
|
||||
home_dir: Option<PathBuf>,
|
||||
) -> Self {
|
||||
// Create channel for receiving repository messages (scans and updates)
|
||||
let (repository_message_tx, repository_message_rx) = async_channel::unbounded();
|
||||
|
||||
// Subscribe to repository messages for both projects and home directory
|
||||
// When a message is received, handle_message is used to dispatch the message to the appropriate handler
|
||||
ctx.spawn_stream_local(
|
||||
repository_message_rx,
|
||||
|me, message, ctx| {
|
||||
me.handle_message(message, ctx);
|
||||
},
|
||||
|_, _| {}, // No cleanup needed when stream ends
|
||||
);
|
||||
|
||||
if home_dir.is_some() {
|
||||
ctx.subscribe_to_model(
|
||||
&HomeDirectoryWatcher::handle(ctx),
|
||||
|me, event, ctx| match event {
|
||||
HomeDirectoryWatcherEvent::HomeFilesChanged(event) => {
|
||||
me.handle_home_files_changed(event, ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
ctx.subscribe_to_model(&WarpManagedPathsWatcher::handle(ctx), |me, event, ctx| {
|
||||
me.handle_warp_managed_paths_event(event, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribe to home directory skills via DirectoryWatcher.
|
||||
//
|
||||
// We watch each skills "parent directory" under the home directory (e.g., `~/.agents`,
|
||||
// `~/.claude`) rather than the entire home directory, to reduce watch overhead.
|
||||
//
|
||||
// Note: This will not create watchers for provider directories that haven't been created yet.
|
||||
// We use a separate HomeDirectoryWatcher to detect when those are created and start watching them after they are created.
|
||||
let mut home_provider_watchers = HashMap::new();
|
||||
if let Some(home_path) = home_dir {
|
||||
Self::spawn_read_skills_from_directories(warp_managed_skill_dirs(), ctx);
|
||||
let skills_parent_paths: HashSet<PathBuf> = SKILL_PROVIDER_DEFINITIONS
|
||||
.iter()
|
||||
.filter(|provider| provider.provider != SkillProvider::Warp)
|
||||
.filter_map(|provider| {
|
||||
home_skills_path(provider.provider)
|
||||
.and_then(|skills_path| skills_path.parent().map(Path::to_path_buf))
|
||||
})
|
||||
.filter(|parent| parent.starts_with(&home_path))
|
||||
.collect();
|
||||
|
||||
for parent_path in skills_parent_paths {
|
||||
Self::watch_home_provider_path(
|
||||
&parent_path,
|
||||
&repository_message_tx,
|
||||
&mut home_provider_watchers,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Two subscriptions handle different aspects of skill loading:
|
||||
//
|
||||
// 1. RepositoryMetadataEvent::RepositoryUpdated - Loads initial skills from the file tree.
|
||||
// This fires after the tree is built, so we can query it for skill directories.
|
||||
//
|
||||
// 2. DetectedRepositoriesEvent::DetectedGitRepo - Sets up file watchers for incremental
|
||||
// updates (add/delete/move). This handles changes after initial load.
|
||||
//
|
||||
// The order of these events doesn't matter - both are idempotent and serve different purposes.
|
||||
ctx.subscribe_to_model(&RepoMetadataModel::handle(ctx), |me, event, ctx| {
|
||||
use repo_metadata::wrapper_model::RepoMetadataEvent;
|
||||
use repo_metadata::RepositoryIdentifier;
|
||||
match event {
|
||||
RepoMetadataEvent::RepositoryUpdated {
|
||||
id: RepositoryIdentifier::Local(path),
|
||||
} => {
|
||||
if let Some(local_path) = path.to_local_path() {
|
||||
me.watch_repo(local_path.clone(), ctx);
|
||||
me.scan_repository_for_skills(&local_path, ctx);
|
||||
}
|
||||
}
|
||||
RepoMetadataEvent::FileTreeEntryUpdated { .. } => {
|
||||
me.handle_queued_project_directory_creations(ctx);
|
||||
}
|
||||
RepoMetadataEvent::RepositoryUpdated { .. }
|
||||
| RepoMetadataEvent::RepositoryRemoved { .. }
|
||||
| RepoMetadataEvent::FileTreeUpdated { .. }
|
||||
| RepoMetadataEvent::UpdatingRepositoryFailed { .. }
|
||||
| RepoMetadataEvent::IncrementalUpdateReady { .. } => {}
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to DetectedRepositories to watch repos registered via CloudEnvironmentPrep.
|
||||
// This fires when AgentDriver calls prepare_environment (for any run with a configured
|
||||
// environment, Warp-hosted or self-hosted). The CloudEnvironmentPrep source filter means
|
||||
// this is a no-op on local runs where no environment is configured.
|
||||
ctx.subscribe_to_model(&DetectedRepositories::handle(ctx), |me, event, ctx| {
|
||||
use repo_metadata::repositories::DetectedRepositoriesEvent;
|
||||
match event {
|
||||
DetectedRepositoriesEvent::DetectedGitRepo { source, .. }
|
||||
if *source == RepoDetectionSource::CloudEnvironmentPrep =>
|
||||
{
|
||||
// The repo root is already registered in DirectoryWatcher by the time
|
||||
// this event fires. Extract its path from the repository handle.
|
||||
let DetectedRepositoriesEvent::DetectedGitRepo { repository, .. } = event;
|
||||
let repo_path = repository.as_ref(ctx).root_dir().to_local_path_lossy();
|
||||
me.watch_repo(repo_path.clone(), ctx);
|
||||
me.scan_repository_for_skills(&repo_path, ctx);
|
||||
}
|
||||
DetectedRepositoriesEvent::DetectedGitRepo { .. } => {}
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
repository_message_tx,
|
||||
watched_repos: HashSet::new(),
|
||||
queued_project_directory_creations: Vec::new(),
|
||||
watcher_event_tx,
|
||||
home_provider_watchers,
|
||||
symlink_canonical_to_originals: HashMap::new(),
|
||||
symlink_target_watchers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a project root path to watch for skill file changes.
|
||||
fn watch_repo(&mut self, repo_path: PathBuf, ctx: &mut ModelContext<Self>) {
|
||||
if self.watched_repos.contains(&repo_path) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the repository handle from DetectedRepositories.
|
||||
if let Some(repo_handle) =
|
||||
DetectedRepositories::as_ref(ctx).get_watched_repo_for_path(&repo_path, ctx)
|
||||
{
|
||||
// Optimistically add the repository to the set of watched repositories to prevent duplicate subscriptions
|
||||
self.watched_repos.insert(repo_path.clone());
|
||||
|
||||
let subscriber = Box::new(ProjectSkillSubscriber {
|
||||
message_tx: self.repository_message_tx.clone(),
|
||||
});
|
||||
|
||||
let start = repo_handle.update(ctx, |repo, ctx| repo.start_watching(subscriber, ctx));
|
||||
ctx.spawn(start.registration_future, move |me, res, ctx| {
|
||||
if let Err(err) = res {
|
||||
log::warn!("Failed to start watching project skills directory: {err}");
|
||||
me.watched_repos.remove(&repo_path);
|
||||
repo_handle.update(ctx, |repo, ctx| {
|
||||
repo.stop_watching(start.subscriber_id, ctx)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Scans a repository for skills using the LocalRepoMetadataModel tree.
|
||||
/// This is called when RepositoryMetadataEvent::RepositoryUpdated fires.
|
||||
fn scan_repository_for_skills(&mut self, repo_path: &Path, ctx: &mut ModelContext<Self>) {
|
||||
let repo_metadata = RepoMetadataModel::as_ref(ctx);
|
||||
|
||||
// Find all skill directories in the tree
|
||||
let skill_dirs = find_skill_directories_in_tree(repo_path, repo_metadata, ctx);
|
||||
if skill_dirs.is_empty() {
|
||||
return;
|
||||
}
|
||||
Self::spawn_read_skills_from_directories(skill_dirs, ctx);
|
||||
}
|
||||
|
||||
fn spawn_read_skills_from_directories(
|
||||
skill_dirs: impl IntoIterator<Item = PathBuf>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let skill_dirs: Vec<_> = skill_dirs.into_iter().collect();
|
||||
if skill_dirs.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.spawn(
|
||||
async move { read_skills_from_directories(skill_dirs) },
|
||||
move |me, skills, ctx| {
|
||||
if !skills.is_empty() {
|
||||
me.register_symlink_watches(&skills, ctx);
|
||||
let _ = me
|
||||
.watcher_event_tx
|
||||
.try_send(SkillWatcherEvent::SkillsAdded { skills });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_message(&mut self, message: SkillRepositoryMessage, ctx: &mut ModelContext<Self>) {
|
||||
match message {
|
||||
SkillRepositoryMessage::HomeInitialScan { skills } => {
|
||||
if skills.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.register_symlink_watches(&skills, ctx);
|
||||
let _ = self
|
||||
.watcher_event_tx
|
||||
.try_send(SkillWatcherEvent::SkillsAdded { skills });
|
||||
}
|
||||
SkillRepositoryMessage::RepositoryUpdate { update } => {
|
||||
self.handle_repository_update(&update, ctx);
|
||||
}
|
||||
SkillRepositoryMessage::SymlinkTargetUpdate { update } => {
|
||||
self.handle_symlink_target_update(&update, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_repository_update(
|
||||
&mut self,
|
||||
update: &RepositoryUpdate,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let mut queued_project_directories = HashSet::new();
|
||||
let mut home_path_additions = HashSet::new();
|
||||
let mut deleted_paths = Vec::new();
|
||||
|
||||
// Process deleted files
|
||||
for target_file in &update.deleted {
|
||||
deleted_paths.push(target_file.path.clone());
|
||||
}
|
||||
|
||||
// Process moved files
|
||||
for (to_target, from_target) in &update.moved {
|
||||
deleted_paths.push(from_target.path.clone());
|
||||
let to_target_path = to_target.path.clone();
|
||||
|
||||
if is_skill_file(&to_target_path) {
|
||||
// read the skill from the file system
|
||||
let skill = parse_skill(&to_target_path);
|
||||
if let Ok(skill) = skill {
|
||||
self.register_symlink_watches(std::slice::from_ref(&skill), ctx);
|
||||
let _ = self
|
||||
.watcher_event_tx
|
||||
.try_send(SkillWatcherEvent::SkillsAdded {
|
||||
skills: vec![skill],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let repo_path = self.get_watched_repo_path(&to_target.path);
|
||||
if let Some(repo_path) = repo_path {
|
||||
if to_target.path.is_dir() {
|
||||
queued_project_directories.insert(repo_path);
|
||||
}
|
||||
} else {
|
||||
home_path_additions.insert(to_target.path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process added or modified files
|
||||
for target_file in update.added_or_modified() {
|
||||
let target_file_path = target_file.path.clone();
|
||||
if is_skill_file(&target_file_path) {
|
||||
// read the skill from the file system
|
||||
ctx.spawn(
|
||||
async move { parse_skill(&target_file_path) },
|
||||
move |me, skill, ctx| {
|
||||
if let Ok(skill) = skill {
|
||||
me.register_symlink_watches(std::slice::from_ref(&skill), ctx);
|
||||
let _ = me
|
||||
.watcher_event_tx
|
||||
.try_send(SkillWatcherEvent::SkillsAdded {
|
||||
skills: vec![skill],
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
} else if target_file.path.is_symlink()
|
||||
&& target_file.path.is_dir()
|
||||
&& target_file.path.join("SKILL.md").exists()
|
||||
{
|
||||
// Newly created symlinked skill directory — read the skill directly
|
||||
// rather than waiting for the queued directory reprocessing cycle.
|
||||
let skill_file_path = target_file.path.join("SKILL.md");
|
||||
ctx.spawn(
|
||||
async move { parse_skill(&skill_file_path) },
|
||||
move |me, skill, ctx| {
|
||||
if let Ok(skill) = skill {
|
||||
me.register_symlink_watches(std::slice::from_ref(&skill), ctx);
|
||||
let _ = me
|
||||
.watcher_event_tx
|
||||
.try_send(SkillWatcherEvent::SkillsAdded {
|
||||
skills: vec![skill],
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
let repo_path = self.get_watched_repo_path(&target_file.path);
|
||||
if let Some(repo_path) = repo_path {
|
||||
if target_file.path.is_dir() {
|
||||
queued_project_directories.insert(repo_path);
|
||||
}
|
||||
} else {
|
||||
home_path_additions.insert(target_file.path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read home directory skills in a batch
|
||||
let home_skill_directories: HashSet<PathBuf> = home_path_additions
|
||||
.into_iter()
|
||||
.filter_map(|path| {
|
||||
// Conditions for potentially being a valid home directory skill or containing skills:
|
||||
// 1. The path is a home directory skill file
|
||||
// 2. The path is a home directory skill directory
|
||||
// 3. The path is a provider path itself under the home directory
|
||||
// We don't need to check #1 because we already checked if this is a skill file
|
||||
if is_home_skill_directory(&path) {
|
||||
let parent_directory = path.parent();
|
||||
parent_directory.map(|parent_directory| parent_directory.to_path_buf())
|
||||
} else if is_home_provider_path(&path) {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !home_skill_directories.is_empty() {
|
||||
ctx.spawn(
|
||||
async move { read_skills_from_directories(home_skill_directories) },
|
||||
move |me, skills, ctx| {
|
||||
if !skills.is_empty() {
|
||||
me.register_symlink_watches(&skills, ctx);
|
||||
let _ = me
|
||||
.watcher_event_tx
|
||||
.try_send(SkillWatcherEvent::SkillsAdded { skills });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Process deleted paths in a batch
|
||||
if !deleted_paths.is_empty() {
|
||||
self.cleanup_symlink_watches(&deleted_paths);
|
||||
let _ = self
|
||||
.watcher_event_tx
|
||||
.try_send(SkillWatcherEvent::SkillsDeleted {
|
||||
paths: deleted_paths,
|
||||
});
|
||||
}
|
||||
|
||||
// Queue project directory creations for later processing since the file tree is not yet updated
|
||||
self.queued_project_directory_creations
|
||||
.extend(queued_project_directories.into_iter().map(|path| {
|
||||
QueuedProjectDirectoryCreation {
|
||||
path,
|
||||
timestamp: DateTime::now().into(),
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn handle_queued_project_directory_creations(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let mut queued_by_repo_path: HashMap<PathBuf, Vec<QueuedProjectDirectoryCreation>> =
|
||||
HashMap::new();
|
||||
|
||||
for queued_project_directory_creation in &self.queued_project_directory_creations {
|
||||
let repo_path = self.get_watched_repo_path(&queued_project_directory_creation.path);
|
||||
if let Some(repo_path) = repo_path {
|
||||
queued_by_repo_path
|
||||
.entry(repo_path)
|
||||
.or_default()
|
||||
.push(queued_project_directory_creation.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut queued_project_directory_creations_to_requeue: Vec<QueuedProjectDirectoryCreation> =
|
||||
Vec::new();
|
||||
let mut skill_dirs_to_read: HashSet<PathBuf> = HashSet::new();
|
||||
|
||||
for (repo_path, queued_project_directory_creations) in queued_by_repo_path {
|
||||
// Find all skill directories in the repository
|
||||
let repo_metadata = RepoMetadataModel::as_ref(ctx);
|
||||
let skill_dirs = find_skill_directories_in_tree(&repo_path, repo_metadata, ctx);
|
||||
if skill_dirs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for queued_project_directory_creation in queued_project_directory_creations {
|
||||
let relevant_skill_dirs = skill_dirs
|
||||
.iter()
|
||||
.filter(|skill_dir| {
|
||||
// If the skill_dir is the child of the new directory, we need to read it again
|
||||
// E.g. new dir is /repo/frontend/feature and skill dir is /repo/frontend/feature/.agents/skills
|
||||
// If the new directory is a child of the skill dir, we need to read it again
|
||||
// E.g. skill_dir is /repo/frontend/.agents/skills and new dir is /repo/frontend/.agents/skills/skill-name
|
||||
skill_dir.starts_with(&queued_project_directory_creation.path)
|
||||
|| queued_project_directory_creation
|
||||
.path
|
||||
.starts_with(skill_dir)
|
||||
})
|
||||
.collect::<Vec<&PathBuf>>();
|
||||
|
||||
// If the file tree doesn't have the newly created directory, we should requeue it for when the file tree is updated again
|
||||
if relevant_skill_dirs.is_empty() {
|
||||
// If 10s after the initial directory creation, the file tree still doesn't have the directory, we will give up and not requeue it
|
||||
let elapsed = DateTime::now()
|
||||
.signed_duration_since(queued_project_directory_creation.timestamp);
|
||||
if elapsed < Duration::seconds(10) {
|
||||
queued_project_directory_creations_to_requeue
|
||||
.push(queued_project_directory_creation.clone());
|
||||
}
|
||||
} else {
|
||||
skill_dirs_to_read.extend(relevant_skill_dirs.into_iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.spawn(
|
||||
async move { read_skills_from_directories(skill_dirs_to_read) },
|
||||
move |me, skills, ctx| {
|
||||
if !skills.is_empty() {
|
||||
me.register_symlink_watches(&skills, ctx);
|
||||
let _ = me
|
||||
.watcher_event_tx
|
||||
.try_send(SkillWatcherEvent::SkillsAdded { skills });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Requeue project directory creations that could not be processed immediately
|
||||
self.queued_project_directory_creations = queued_project_directory_creations_to_requeue;
|
||||
}
|
||||
|
||||
/// Cleans up symlink canonical→original mappings for deleted skill paths.
|
||||
///
|
||||
/// The subscriber and `DirectoryWatcher` entry for the canonical directory
|
||||
/// are intentionally kept alive so that if the symlink is re-created later,
|
||||
/// the event still reaches `handle_symlink_target_update` and is handled
|
||||
/// as a new symlink skill.
|
||||
fn cleanup_symlink_watches(&mut self, deleted_paths: &[PathBuf]) {
|
||||
let mut empty_canonicals = Vec::new();
|
||||
|
||||
for (canonical, originals) in &mut self.symlink_canonical_to_originals {
|
||||
originals.retain(|original| {
|
||||
!deleted_paths
|
||||
.iter()
|
||||
.any(|deleted| original.starts_with(deleted) || original == deleted)
|
||||
});
|
||||
if originals.is_empty() {
|
||||
empty_canonicals.push(canonical.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for canonical_path in empty_canonicals {
|
||||
self.symlink_canonical_to_originals.remove(&canonical_path);
|
||||
}
|
||||
}
|
||||
|
||||
/// For each loaded skill, check whether it lives behind a symlink. If so,
|
||||
/// resolve the canonical path and register a watch on the target directory
|
||||
/// via `DirectoryWatcher` so that modifications to the real file are detected.
|
||||
fn register_symlink_watches(&mut self, skills: &[ParsedSkill], ctx: &mut ModelContext<Self>) {
|
||||
for skill in skills {
|
||||
let original_path = &skill.path;
|
||||
let Ok(canonical_path) = dunce::canonicalize(original_path) else {
|
||||
continue;
|
||||
};
|
||||
if canonical_path == *original_path {
|
||||
continue; // Not a symlink
|
||||
}
|
||||
|
||||
self.symlink_canonical_to_originals
|
||||
.entry(canonical_path.clone())
|
||||
.or_default()
|
||||
.insert(original_path.clone());
|
||||
|
||||
let Some(canonical_dir) = canonical_path.parent() else {
|
||||
continue;
|
||||
};
|
||||
let canonical_dir = canonical_dir.to_path_buf();
|
||||
if self.symlink_target_watchers.contains_key(&canonical_dir) {
|
||||
continue; // Already watched
|
||||
}
|
||||
|
||||
let Ok(std_dir_path) =
|
||||
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(
|
||||
&canonical_dir,
|
||||
)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let dir_display = canonical_dir.display().to_string();
|
||||
let repo_handle = match DirectoryWatcher::handle(ctx)
|
||||
.update(ctx, |watcher, ctx| watcher.add_directory(std_dir_path, ctx))
|
||||
{
|
||||
Ok(handle) => handle,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to register symlink target directory {dir_display} for watching: {err}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let subscriber = Box::new(SymlinkSkillSubscriber {
|
||||
message_tx: self.repository_message_tx.clone(),
|
||||
});
|
||||
let start = repo_handle.update(ctx, |repo, ctx| repo.start_watching(subscriber, ctx));
|
||||
let subscriber_id = start.subscriber_id;
|
||||
self.symlink_target_watchers
|
||||
.insert(canonical_dir.clone(), (repo_handle.clone(), subscriber_id));
|
||||
|
||||
ctx.spawn(start.registration_future, move |me, res, ctx| {
|
||||
if let Err(err) = res {
|
||||
log::warn!(
|
||||
"Failed to start watching symlink target directory {dir_display}: {err}"
|
||||
);
|
||||
me.symlink_target_watchers.remove(&canonical_dir);
|
||||
repo_handle.update(ctx, |repo, ctx| {
|
||||
repo.stop_watching(subscriber_id, ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle file changes detected in a resolved symlink target directory.
|
||||
/// Maps canonical paths back to their original symlink-based skill paths
|
||||
/// and re-reads the affected skills.
|
||||
fn handle_symlink_target_update(
|
||||
&mut self,
|
||||
update: &RepositoryUpdate,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// When the real file behind a symlink is deleted, emit SkillsDeleted
|
||||
// so the SkillManager removes the stale entry.
|
||||
let deleted_original_paths: Vec<PathBuf> = update
|
||||
.deleted
|
||||
.iter()
|
||||
.flat_map(|target_file| {
|
||||
// Exact canonical match
|
||||
let exact = self
|
||||
.symlink_canonical_to_originals
|
||||
.get(&target_file.path)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.cloned();
|
||||
// Also match when a parent directory of the canonical path is deleted
|
||||
let ancestor = self
|
||||
.symlink_canonical_to_originals
|
||||
.iter()
|
||||
.filter(|(canonical, _)| canonical.starts_with(&target_file.path))
|
||||
.flat_map(|(_, originals)| originals.iter().cloned());
|
||||
exact.chain(ancestor)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !deleted_original_paths.is_empty() {
|
||||
self.cleanup_symlink_watches(&deleted_original_paths);
|
||||
let _ = self
|
||||
.watcher_event_tx
|
||||
.try_send(SkillWatcherEvent::SkillsDeleted {
|
||||
paths: deleted_original_paths,
|
||||
});
|
||||
}
|
||||
|
||||
for target_file in update.added_or_modified() {
|
||||
if let Some(original_paths) = self.symlink_canonical_to_originals.get(&target_file.path)
|
||||
{
|
||||
for original_path in original_paths.clone() {
|
||||
ctx.spawn(
|
||||
async move { parse_skill(&original_path) },
|
||||
|me, skill, _| {
|
||||
if let Ok(skill) = skill {
|
||||
let _ =
|
||||
me.watcher_event_tx
|
||||
.try_send(SkillWatcherEvent::SkillsAdded {
|
||||
skills: vec![skill],
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
} else if target_file.path.is_symlink()
|
||||
&& target_file.path.is_dir()
|
||||
&& target_file.path.join("SKILL.md").exists()
|
||||
{
|
||||
// A symlink skill directory was (re-)created. The event routed here
|
||||
// because the DirectoryWatcher entry for the canonical target still
|
||||
// exists from a previous registration. Parse the skill and re-register.
|
||||
let skill_file_path = target_file.path.join("SKILL.md");
|
||||
ctx.spawn(
|
||||
async move { parse_skill(&skill_file_path) },
|
||||
move |me, skill, ctx| {
|
||||
if let Ok(skill) = skill {
|
||||
me.register_symlink_watches(std::slice::from_ref(&skill), ctx);
|
||||
let _ = me
|
||||
.watcher_event_tx
|
||||
.try_send(SkillWatcherEvent::SkillsAdded {
|
||||
skills: vec![skill],
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Given a path, return the path of the watched repository, if any.
|
||||
fn get_watched_repo_path(&self, path: &Path) -> Option<PathBuf> {
|
||||
self.watched_repos
|
||||
.iter()
|
||||
.find(|repo_path| path.starts_with(repo_path))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Handle changes to top-level files in the home directory.
|
||||
/// For skills, these are newly created provider directories
|
||||
fn handle_home_files_changed(
|
||||
&mut self,
|
||||
event: &BulkFilesystemWatcherEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let mut deleted_paths = Vec::new();
|
||||
let mut added_paths = Vec::new();
|
||||
|
||||
let provider_root_paths: HashSet<String> = SKILL_PROVIDER_DEFINITIONS
|
||||
.iter()
|
||||
.filter(|provider| provider.provider != SkillProvider::Warp)
|
||||
.filter_map(|provider| {
|
||||
let component = provider.skills_path.components().next();
|
||||
component.map(|component| component.as_os_str().to_string_lossy().to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Process deleted files
|
||||
for target_file in event.deleted.iter() {
|
||||
let file_name = target_file
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
if provider_root_paths.contains(&file_name) {
|
||||
deleted_paths.push(target_file.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Process moved files
|
||||
for (to_target, from_target) in event.moved.iter() {
|
||||
let from_file_name = from_target
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
if provider_root_paths.contains(&from_file_name) {
|
||||
deleted_paths.push(from_target.clone());
|
||||
}
|
||||
let to_file_name = to_target
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
if provider_root_paths.contains(&to_file_name) {
|
||||
added_paths.push(to_target.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Process added files
|
||||
// We don't care about modified files because that doesn't affect existing watchers
|
||||
for target_file in event.added.iter() {
|
||||
let file_name = target_file
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
if provider_root_paths.contains(&file_name) {
|
||||
added_paths.push(target_file.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up directory watchers for deleted provider paths.
|
||||
for deleted_path in &deleted_paths {
|
||||
if let Some((repo_handle, subscriber_id)) =
|
||||
self.home_provider_watchers.remove(deleted_path)
|
||||
{
|
||||
repo_handle.update(ctx, |repo, ctx| {
|
||||
repo.stop_watching(subscriber_id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !deleted_paths.is_empty() {
|
||||
let _ = self
|
||||
.watcher_event_tx
|
||||
.try_send(SkillWatcherEvent::SkillsDeleted {
|
||||
paths: deleted_paths,
|
||||
});
|
||||
}
|
||||
|
||||
for added_path in added_paths {
|
||||
// For each newly added provider root path, add a watcher for it
|
||||
Self::watch_home_provider_path(
|
||||
&added_path,
|
||||
&self.repository_message_tx,
|
||||
&mut self.home_provider_watchers,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_warp_managed_paths_event(
|
||||
&mut self,
|
||||
event: &WarpManagedPathsWatcherEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let WarpManagedPathsWatcherEvent::FilesChanged(update) = event;
|
||||
for skill_dir in warp_managed_skill_dirs() {
|
||||
if let Some(filtered_update) = filter_repository_update_by_prefix(update, &skill_dir) {
|
||||
self.handle_repository_update(&filtered_update, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch a provider path in the home directory (e.g. ~/.agents), storing the handle
|
||||
/// and subscriber ID in `home_provider_watchers` so the watcher can be cleaned up
|
||||
/// when the directory is deleted.
|
||||
fn watch_home_provider_path(
|
||||
path: &Path,
|
||||
repository_message_tx: &Sender<SkillRepositoryMessage>,
|
||||
home_provider_watchers: &mut HashMap<PathBuf, (ModelHandle<Repository>, SubscriberId)>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Ok(std_path) =
|
||||
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(path)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let subscriber = Box::new(HomeSkillSubscriber {
|
||||
message_tx: repository_message_tx.clone(),
|
||||
});
|
||||
|
||||
let parent_path_display = std_path.to_string();
|
||||
let repo_handle = match DirectoryWatcher::handle(ctx)
|
||||
.update(ctx, |watcher, ctx| watcher.add_directory(std_path, ctx))
|
||||
{
|
||||
Ok(handle) => handle,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to register home skills directory {parent_path_display} for watching: {err}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let start = repo_handle.update(ctx, |repo, ctx| repo.start_watching(subscriber, ctx));
|
||||
let subscriber_id = start.subscriber_id;
|
||||
|
||||
// Store the watcher so it can be cleaned up if the directory is deleted.
|
||||
home_provider_watchers.insert(path.to_path_buf(), (repo_handle.clone(), subscriber_id));
|
||||
|
||||
let path_owned = path.to_path_buf();
|
||||
ctx.spawn(start.registration_future, move |me, res, ctx| {
|
||||
if let Err(err) = res {
|
||||
log::warn!(
|
||||
"Failed to start watching home skills directory {parent_path_display}: {err}"
|
||||
);
|
||||
// Remove the stored watcher since registration failed.
|
||||
me.home_provider_watchers.remove(&path_owned);
|
||||
repo_handle.update(ctx, |repo, ctx| {
|
||||
repo.stop_watching(subscriber_id, ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SkillWatcher {
|
||||
type Event = SkillWatcherEvent;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "skill_watcher_tests.rs"]
|
||||
mod skill_watcher_tests;
|
||||
@@ -0,0 +1,398 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
};
|
||||
|
||||
use crate::ai::skills::skill_manager::SkillWatcherEvent;
|
||||
use ai::skills::{ParsedSkill, SkillProvider, SkillScope};
|
||||
use repo_metadata::{
|
||||
repositories::DetectedRepositories, DirectoryWatcher, RepoMetadataModel, RepositoryUpdate,
|
||||
TargetFile,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
use warpui::App;
|
||||
|
||||
use super::SkillWatcher;
|
||||
|
||||
/// Helper function for creating a single skill file
|
||||
fn create_skill_file(dir: &TempDir, name: &str, description: &str, content: &str) -> ParsedSkill {
|
||||
let skill_content = format!(
|
||||
r#"---
|
||||
name: {}
|
||||
description: {}
|
||||
---
|
||||
{}
|
||||
"#,
|
||||
name, description, content
|
||||
);
|
||||
let skills_path = dir.path().join(".agents").join("skills");
|
||||
let skill_dir_path = skills_path.join(name);
|
||||
let skill_file_path = skill_dir_path.join("SKILL.md");
|
||||
|
||||
fs::create_dir_all(&skill_dir_path).unwrap();
|
||||
fs::write(&skill_file_path, skill_content.clone()).unwrap();
|
||||
let line_range_start = skill_content.clone().lines().count() - content.lines().count() + 1;
|
||||
let line_range_end = skill_content.clone().lines().count() + 1;
|
||||
ParsedSkill {
|
||||
path: skill_file_path,
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
content: skill_content.clone(),
|
||||
line_range: Some(line_range_start..line_range_end),
|
||||
provider: SkillProvider::Agents,
|
||||
scope: SkillScope::Project,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests for handle_repository_update
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_handle_repository_update_single_skill_added() {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new_for_testing);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skill = create_skill_file(&temp_dir, "test", "Test skill", "Test content");
|
||||
|
||||
let update = RepositoryUpdate {
|
||||
added: HashSet::from([TargetFile::new(skill.path.clone(), false)]),
|
||||
modified: HashSet::new(),
|
||||
deleted: HashSet::new(),
|
||||
moved: HashMap::new(),
|
||||
commit_updated: false,
|
||||
index_lock_detected: false,
|
||||
};
|
||||
|
||||
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
|
||||
skill_watcher.handle_repository_update(&update, ctx);
|
||||
});
|
||||
|
||||
let event = rx.recv().await.unwrap();
|
||||
assert_eq!(
|
||||
event,
|
||||
SkillWatcherEvent::SkillsAdded {
|
||||
skills: vec![skill]
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_repository_update_skill_modified() {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new_for_testing);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skill = create_skill_file(&temp_dir, "test", "Test skill", "Test content");
|
||||
|
||||
let update = RepositoryUpdate {
|
||||
added: HashSet::new(),
|
||||
modified: HashSet::from([TargetFile::new(skill.path.clone(), false)]),
|
||||
deleted: HashSet::new(),
|
||||
moved: HashMap::new(),
|
||||
commit_updated: false,
|
||||
index_lock_detected: false,
|
||||
};
|
||||
|
||||
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
|
||||
skill_watcher.handle_repository_update(&update, ctx);
|
||||
});
|
||||
|
||||
let event = rx.recv().await.unwrap();
|
||||
assert_eq!(
|
||||
event,
|
||||
SkillWatcherEvent::SkillsAdded {
|
||||
skills: vec![skill]
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_repository_update_skill_deleted() {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new_for_testing);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skill = create_skill_file(&temp_dir, "test", "Test skill", "Test content");
|
||||
|
||||
let update = RepositoryUpdate {
|
||||
added: HashSet::new(),
|
||||
modified: HashSet::new(),
|
||||
deleted: HashSet::from([TargetFile::new(skill.path.clone(), false)]),
|
||||
moved: HashMap::new(),
|
||||
commit_updated: false,
|
||||
index_lock_detected: false,
|
||||
};
|
||||
|
||||
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
|
||||
skill_watcher.handle_repository_update(&update, ctx);
|
||||
});
|
||||
|
||||
let event = rx.recv().await.unwrap();
|
||||
assert_eq!(
|
||||
event,
|
||||
SkillWatcherEvent::SkillsDeleted {
|
||||
paths: vec![skill.path]
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_repository_update_multiple_skills_deleted() {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new_for_testing);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skill_a = create_skill_file(&temp_dir, "skill-a", "Skill A", "Content A");
|
||||
let skill_b = create_skill_file(&temp_dir, "skill-b", "Skill B", "Content B");
|
||||
|
||||
let update = RepositoryUpdate {
|
||||
added: HashSet::new(),
|
||||
modified: HashSet::new(),
|
||||
deleted: HashSet::from([
|
||||
TargetFile::new(skill_a.path.clone(), false),
|
||||
TargetFile::new(skill_b.path.clone(), false),
|
||||
]),
|
||||
moved: HashMap::new(),
|
||||
commit_updated: false,
|
||||
index_lock_detected: false,
|
||||
};
|
||||
|
||||
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
|
||||
skill_watcher.handle_repository_update(&update, ctx);
|
||||
});
|
||||
|
||||
let event = rx.recv().await.unwrap();
|
||||
let SkillWatcherEvent::SkillsDeleted { mut paths } = event else {
|
||||
panic!("Expected SkillsDeleted event");
|
||||
};
|
||||
paths.sort();
|
||||
let mut expected = vec![skill_a.path, skill_b.path];
|
||||
expected.sort();
|
||||
assert_eq!(paths, expected);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_repository_update_skill_moved() {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new_for_testing);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let old_skill = create_skill_file(&temp_dir, "old-skill", "Old skill", "Old content");
|
||||
let new_skill = create_skill_file(&temp_dir, "new-skill", "New skill", "New content");
|
||||
|
||||
// moved is HashMap<to_target, from_target>
|
||||
let update = RepositoryUpdate {
|
||||
added: HashSet::new(),
|
||||
modified: HashSet::new(),
|
||||
deleted: HashSet::new(),
|
||||
moved: HashMap::from([(
|
||||
TargetFile::new(new_skill.path.clone(), false),
|
||||
TargetFile::new(old_skill.path.clone(), false),
|
||||
)]),
|
||||
commit_updated: false,
|
||||
index_lock_detected: false,
|
||||
};
|
||||
|
||||
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
|
||||
skill_watcher.handle_repository_update(&update, ctx);
|
||||
});
|
||||
|
||||
// Collect both events: SkillsAdded for the new location and SkillsDeleted for the old
|
||||
let event1 = rx.recv().await.unwrap();
|
||||
let event2 = rx.recv().await.unwrap();
|
||||
|
||||
let added_event = SkillWatcherEvent::SkillsAdded {
|
||||
skills: vec![new_skill],
|
||||
};
|
||||
let deleted_event = SkillWatcherEvent::SkillsDeleted {
|
||||
paths: vec![old_skill.path],
|
||||
};
|
||||
assert!(
|
||||
(event1 == added_event && event2 == deleted_event)
|
||||
|| (event1 == deleted_event && event2 == added_event),
|
||||
"Expected one SkillsAdded and one SkillsDeleted event; got: {event1:?} and {event2:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests for handle_repository_update - directory addition
|
||||
// ============================================================================
|
||||
|
||||
/// When a non-skill directory is added within a known repo, `handle_repository_update` should
|
||||
/// queue the repo root in `queued_project_directory_creations` for a later skill scan.
|
||||
#[test]
|
||||
fn test_handle_repository_update_non_skill_directory_added_queues_project_directory() {
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new_for_testing);
|
||||
let detected_repos_handle = app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
let (tx, _rx) = async_channel::unbounded();
|
||||
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let canonical_repo = StandardizedPath::from_local_canonicalized(temp_dir.path()).unwrap();
|
||||
|
||||
// Register the temp dir as a known repo root so get_root_for_path resolves it.
|
||||
detected_repos_handle.update(&mut app, |repos, _| {
|
||||
repos.insert_test_repo_root(canonical_repo.clone());
|
||||
});
|
||||
|
||||
// Seed watched_repos so get_watched_repo_path can resolve the temp dir to this root.
|
||||
// Use the canonicalized path to match what CanonicalizedPath::try_from resolves on macOS
|
||||
// (where /var is a symlink to /private/var).
|
||||
skill_watcher_handle.update(&mut app, |watcher, _| {
|
||||
watcher
|
||||
.watched_repos
|
||||
.insert(canonical_repo.to_local_path().unwrap());
|
||||
});
|
||||
|
||||
// The added path must exist on disk for CanonicalizedPath resolution.
|
||||
let new_dir = canonical_repo.to_local_path().unwrap().join("new-feature");
|
||||
fs::create_dir_all(&new_dir).unwrap();
|
||||
|
||||
let update = RepositoryUpdate {
|
||||
added: HashSet::from([TargetFile::new(new_dir, false)]),
|
||||
modified: HashSet::new(),
|
||||
deleted: HashSet::new(),
|
||||
moved: HashMap::new(),
|
||||
commit_updated: false,
|
||||
index_lock_detected: false,
|
||||
};
|
||||
|
||||
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
|
||||
skill_watcher.handle_repository_update(&update, ctx);
|
||||
});
|
||||
|
||||
// The repo root should be queued for a skill scan.
|
||||
skill_watcher_handle.read(&app, |watcher, _| {
|
||||
assert_eq!(watcher.queued_project_directory_creations.len(), 1);
|
||||
assert_eq!(
|
||||
watcher.queued_project_directory_creations[0].path,
|
||||
canonical_repo.to_local_path().unwrap()
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A modified non-skill file in a known repo should NOT queue anything in
|
||||
/// `queued_project_directory_creations`; only directory additions can introduce new skill files.
|
||||
#[test]
|
||||
fn test_handle_repository_update_non_skill_file_modified_in_repo_does_not_queue_project_directory()
|
||||
{
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new_for_testing);
|
||||
let detected_repos_handle = app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
let (tx, _rx) = async_channel::unbounded();
|
||||
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let canonical_repo = StandardizedPath::from_local_canonicalized(temp_dir.path()).unwrap();
|
||||
|
||||
detected_repos_handle.update(&mut app, |repos, _| {
|
||||
repos.insert_test_repo_root(canonical_repo.clone());
|
||||
});
|
||||
|
||||
// Create the file on disk so CanonicalizedPath resolution succeeds.
|
||||
let readme = temp_dir.path().join("README.md");
|
||||
fs::write(&readme, "# Project").unwrap();
|
||||
|
||||
let update = RepositoryUpdate {
|
||||
added: HashSet::new(),
|
||||
modified: HashSet::from([TargetFile::new(readme, false)]),
|
||||
deleted: HashSet::new(),
|
||||
moved: HashMap::new(),
|
||||
commit_updated: false,
|
||||
index_lock_detected: false,
|
||||
};
|
||||
|
||||
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
|
||||
skill_watcher.handle_repository_update(&update, ctx);
|
||||
});
|
||||
|
||||
// Modifying a plain file must NOT queue a project directory scan.
|
||||
skill_watcher_handle.read(&app, |watcher, _| {
|
||||
assert_eq!(watcher.queued_project_directory_creations.len(), 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// When a regular (non-skill) file is added within a known repo, `handle_repository_update`
|
||||
/// should NOT queue anything in `queued_project_directory_creations` because only directory
|
||||
/// additions may introduce new skill files.
|
||||
#[test]
|
||||
fn test_handle_repository_update_non_skill_file_added_does_not_queue_project_directory() {
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new_for_testing);
|
||||
let detected_repos_handle = app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
let (tx, _rx) = async_channel::unbounded();
|
||||
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let canonical_repo = StandardizedPath::from_local_canonicalized(temp_dir.path()).unwrap();
|
||||
|
||||
detected_repos_handle.update(&mut app, |repos, _| {
|
||||
repos.insert_test_repo_root(canonical_repo.clone());
|
||||
});
|
||||
|
||||
// Create a regular file (not a directory, not a skill file) on disk.
|
||||
let readme = temp_dir.path().join("README.md");
|
||||
fs::write(&readme, "# Project").unwrap();
|
||||
|
||||
let update = RepositoryUpdate {
|
||||
added: HashSet::from([TargetFile::new(readme, false)]),
|
||||
modified: HashSet::new(),
|
||||
deleted: HashSet::new(),
|
||||
moved: HashMap::new(),
|
||||
commit_updated: false,
|
||||
index_lock_detected: false,
|
||||
};
|
||||
|
||||
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
|
||||
skill_watcher.handle_repository_update(&update, ctx);
|
||||
});
|
||||
|
||||
// A plain file being added must NOT queue a project directory scan.
|
||||
skill_watcher_handle.read(&app, |watcher, _| {
|
||||
assert_eq!(watcher.queued_project_directory_creations.len(), 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests for handle_queued_project_directory_creations
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,139 @@
|
||||
use std::pin::Pin;
|
||||
|
||||
use futures::Future;
|
||||
|
||||
use ai::skills::{read_skills, ParsedSkill, SKILL_PROVIDER_DEFINITIONS};
|
||||
use async_channel::Sender;
|
||||
use repo_metadata::{repository::RepositorySubscriber, Repository, RepositoryUpdate};
|
||||
use warpui::ModelContext;
|
||||
|
||||
/// Messages sent from [`RepositorySubscriber`]s to [`SkillManager`].
|
||||
pub enum SkillRepositoryMessage {
|
||||
/// Initial scan of a home skills directory (e.g., `~/.agents`).
|
||||
HomeInitialScan { skills: Vec<ParsedSkill> },
|
||||
/// Incremental file system updates from either a home provider directory or a project skills directory.
|
||||
RepositoryUpdate { update: RepositoryUpdate },
|
||||
/// File changes detected in a resolved symlink target directory.
|
||||
SymlinkTargetUpdate { update: RepositoryUpdate },
|
||||
}
|
||||
|
||||
/// A repository subscriber for project directories that forwards file change events to [`SkillManager`].
|
||||
pub struct ProjectSkillSubscriber {
|
||||
pub message_tx: Sender<SkillRepositoryMessage>,
|
||||
}
|
||||
|
||||
impl RepositorySubscriber for ProjectSkillSubscriber {
|
||||
fn on_scan(
|
||||
&mut self,
|
||||
_repository: &Repository,
|
||||
_ctx: &mut ModelContext<Repository>,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
|
||||
// Initial skill scanning is handled via RepositoryMetadataEvent::RepositoryUpdated,
|
||||
// which fires AFTER the file tree is built. This subscriber is only used for
|
||||
// incremental file change updates via on_files_updated.
|
||||
Box::pin(async {})
|
||||
}
|
||||
|
||||
fn on_files_updated(
|
||||
&mut self,
|
||||
_repository: &Repository,
|
||||
update: &RepositoryUpdate,
|
||||
_ctx: &mut ModelContext<Repository>,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
|
||||
let tx = self.message_tx.clone();
|
||||
let update = update.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
let _ = tx
|
||||
.send(SkillRepositoryMessage::RepositoryUpdate { update })
|
||||
.await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A repository subscriber for resolved symlink target directories.
|
||||
/// Forwards file change events as `SymlinkTargetUpdate` so the `SkillWatcher`
|
||||
/// can map canonical paths back to their original symlink-based skill paths.
|
||||
pub struct SymlinkSkillSubscriber {
|
||||
pub message_tx: Sender<SkillRepositoryMessage>,
|
||||
}
|
||||
|
||||
impl RepositorySubscriber for SymlinkSkillSubscriber {
|
||||
fn on_scan(
|
||||
&mut self,
|
||||
_repository: &Repository,
|
||||
_ctx: &mut ModelContext<Repository>,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
|
||||
// Skills are already loaded at the symlink path; no initial scan needed.
|
||||
Box::pin(async {})
|
||||
}
|
||||
|
||||
fn on_files_updated(
|
||||
&mut self,
|
||||
_repository: &Repository,
|
||||
update: &RepositoryUpdate,
|
||||
_ctx: &mut ModelContext<Repository>,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
|
||||
let tx = self.message_tx.clone();
|
||||
let update = update.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
let _ = tx
|
||||
.send(SkillRepositoryMessage::SymlinkTargetUpdate { update })
|
||||
.await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A repository subscriber for home skills directories that forwards file change events to [`SkillManager`].
|
||||
pub struct HomeSkillSubscriber {
|
||||
pub message_tx: Sender<SkillRepositoryMessage>,
|
||||
}
|
||||
|
||||
impl RepositorySubscriber for HomeSkillSubscriber {
|
||||
fn on_scan(
|
||||
&mut self,
|
||||
repository: &Repository,
|
||||
_ctx: &mut ModelContext<Repository>,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
|
||||
// For a home skills directory, this is the provider directory. E.g. ~/.agents
|
||||
let repo_path = repository.root_dir().to_local_path_lossy();
|
||||
let home_dir = dirs::home_dir();
|
||||
let tx = self.message_tx.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
if let Some(home_dir) = home_dir {
|
||||
let skills_path = SKILL_PROVIDER_DEFINITIONS.iter().find_map(|definition| {
|
||||
let candidate_path = home_dir.join(definition.skills_path.clone());
|
||||
if candidate_path.starts_with(&repo_path) {
|
||||
Some(candidate_path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(skills_path) = skills_path {
|
||||
let skills = read_skills(&skills_path);
|
||||
let _ = tx
|
||||
.send(SkillRepositoryMessage::HomeInitialScan { skills })
|
||||
.await;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn on_files_updated(
|
||||
&mut self,
|
||||
_repository: &Repository,
|
||||
update: &RepositoryUpdate,
|
||||
_ctx: &mut ModelContext<Repository>,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
|
||||
let tx = self.message_tx.clone();
|
||||
let update = update.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
let _ = tx
|
||||
.send(SkillRepositoryMessage::RepositoryUpdate { update })
|
||||
.await;
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::{Path, PathBuf},
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
use ai::skills::{
|
||||
home_skills_path, read_skills, ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS,
|
||||
};
|
||||
use anyhow::Error;
|
||||
use regex::Regex;
|
||||
use repo_metadata::{local_model::GetContentsArgs, RepoContent, RepoMetadataModel};
|
||||
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.
|
||||
///
|
||||
/// 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,
|
||||
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();
|
||||
};
|
||||
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()
|
||||
}
|
||||
|
||||
/// Reads all skills from the given skill directories.
|
||||
pub fn read_skills_from_directories(
|
||||
skill_dirs: impl IntoIterator<Item = PathBuf>,
|
||||
) -> Vec<ParsedSkill> {
|
||||
skill_dirs
|
||||
.into_iter()
|
||||
.flat_map(|dir| read_skills(&dir))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn is_skill_file(path: &Path) -> bool {
|
||||
extract_skill_parent_directory(path).is_ok()
|
||||
}
|
||||
|
||||
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
|
||||
#[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> {
|
||||
let is_warp_home_skill = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name == "SKILL.md")
|
||||
&& path
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.is_some_and(|parent| warp_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()));
|
||||
}
|
||||
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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this path is a home directory provider 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 {
|
||||
return warp_managed_skill_dirs().iter().any(|dir| path == dir);
|
||||
}
|
||||
home_skills_path(provider.provider)
|
||||
.as_ref()
|
||||
.is_some_and(|home_skills_path| path == home_skills_path)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "utils_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,726 @@
|
||||
use repo_metadata::{
|
||||
entry::{DirectoryEntry, Entry, FileMetadata},
|
||||
file_tree_store::FileTreeState,
|
||||
repositories::DetectedRepositories,
|
||||
DirectoryWatcher, RepoMetadataModel,
|
||||
};
|
||||
use virtual_fs::{Stub, VirtualFS};
|
||||
use warpui::App;
|
||||
|
||||
use super::{
|
||||
extract_skill_parent_directory, find_skill_directories_in_tree, is_home_provider_path,
|
||||
is_home_skill_directory, is_skill_file, read_skills_from_directories,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Tests for is_skill_file
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn is_skill_file_valid_paths() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
|
||||
let path = home_dir
|
||||
.join("repos")
|
||||
.join("project")
|
||||
.join(".agents")
|
||||
.join("skills")
|
||||
.join("my-skill")
|
||||
.join("SKILL.md");
|
||||
assert!(is_skill_file(&path));
|
||||
|
||||
let path = home_dir
|
||||
.join(".claude")
|
||||
.join("skills")
|
||||
.join("test-skill")
|
||||
.join("SKILL.md");
|
||||
assert!(is_skill_file(&path));
|
||||
|
||||
// Path with multiple levels of prefix
|
||||
let path = home_dir
|
||||
.join("very")
|
||||
.join("deep")
|
||||
.join("path")
|
||||
.join("to")
|
||||
.join("repo")
|
||||
.join(".agents")
|
||||
.join("skills")
|
||||
.join("another-skill")
|
||||
.join("SKILL.md");
|
||||
assert!(is_skill_file(&path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_skill_file_invalid_provider() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
|
||||
let path = home_dir
|
||||
.join("repos")
|
||||
.join("project")
|
||||
.join(".garbage")
|
||||
.join("skills")
|
||||
.join("my-skill")
|
||||
.join("SKILL.md");
|
||||
assert!(!is_skill_file(&path));
|
||||
|
||||
let path = home_dir
|
||||
.join(".garbage")
|
||||
.join("skills")
|
||||
.join("test-skill")
|
||||
.join("SKILL.md");
|
||||
assert!(!is_skill_file(&path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_skill_file_invalid_format() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
|
||||
// Missing SKILL.md at the end
|
||||
let path = home_dir
|
||||
.join("repos")
|
||||
.join("project")
|
||||
.join(".agents")
|
||||
.join("skills")
|
||||
.join("my-skill")
|
||||
.join("README.md");
|
||||
assert!(!is_skill_file(&path));
|
||||
|
||||
// No skill name directory
|
||||
let path = home_dir
|
||||
.join("repos")
|
||||
.join("project")
|
||||
.join(".agents")
|
||||
.join("skills")
|
||||
.join("SKILL.md");
|
||||
assert!(!is_skill_file(&path));
|
||||
|
||||
// Extra directory level in skill name
|
||||
let path = home_dir
|
||||
.join("repos")
|
||||
.join("project")
|
||||
.join(".agents")
|
||||
.join("skills")
|
||||
.join("nested")
|
||||
.join("my-skill")
|
||||
.join("SKILL.md");
|
||||
assert!(!is_skill_file(&path));
|
||||
|
||||
// Missing provider directory
|
||||
let path = home_dir
|
||||
.join("repos")
|
||||
.join("project")
|
||||
.join("skills")
|
||||
.join("my-skill")
|
||||
.join("SKILL.md");
|
||||
assert!(!is_skill_file(&path));
|
||||
|
||||
// Plain file path
|
||||
let path = home_dir.join("some").join("random").join("file.txt");
|
||||
assert!(!is_skill_file(&path));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests for extract_skill_parent_directory
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn extract_skill_parent_directory_from_repo_root() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
let parent_directory = home_dir.join("repo");
|
||||
let skill_path = parent_directory
|
||||
.join(".agents")
|
||||
.join("skills")
|
||||
.join("my-skill")
|
||||
.join("SKILL.md");
|
||||
let result = extract_skill_parent_directory(&skill_path);
|
||||
assert_eq!(result.ok(), Some(parent_directory));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_skill_parent_directory_from_subdirectory() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
let parent_directory = home_dir.join("repo").join("packages").join("frontend");
|
||||
let skill_path = parent_directory
|
||||
.join(".agents")
|
||||
.join("skills")
|
||||
.join("build")
|
||||
.join("SKILL.md");
|
||||
let result = extract_skill_parent_directory(&skill_path);
|
||||
assert_eq!(result.ok(), Some(parent_directory));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_skill_parent_directory_from_deep_subdirectory() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
let parent_directory = home_dir
|
||||
.join("repo")
|
||||
.join("a")
|
||||
.join("b")
|
||||
.join("c")
|
||||
.join("d");
|
||||
let skill_path = parent_directory
|
||||
.join(".claude")
|
||||
.join("skills")
|
||||
.join("test-skill")
|
||||
.join("SKILL.md");
|
||||
let result = extract_skill_parent_directory(&skill_path);
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(parent_directory),
|
||||
"Failed for path: {}",
|
||||
skill_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_skill_parent_directory_different_providers() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
let repo = home_dir.join("repo");
|
||||
let providers = [".warp", ".claude", ".codex", ".cursor", ".gemini"];
|
||||
for provider in providers {
|
||||
let path = repo
|
||||
.join(provider)
|
||||
.join("skills")
|
||||
.join("s")
|
||||
.join("SKILL.md");
|
||||
let result = extract_skill_parent_directory(&path);
|
||||
assert_eq!(
|
||||
result.ok(),
|
||||
Some(repo.clone()),
|
||||
"Failed for path: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_skill_parent_directory_returns_none_for_non_skill() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
|
||||
// Not a SKILL.md file
|
||||
let path = home_dir
|
||||
.join("repo")
|
||||
.join(".agents")
|
||||
.join("skills")
|
||||
.join("my-skill")
|
||||
.join("README.md");
|
||||
assert_eq!(extract_skill_parent_directory(&path).ok(), None);
|
||||
|
||||
// Wrong structure (skill directly in skills dir)
|
||||
let path = home_dir
|
||||
.join("repo")
|
||||
.join(".agents")
|
||||
.join("skills")
|
||||
.join("SKILL.md");
|
||||
assert_eq!(extract_skill_parent_directory(&path).ok(), None);
|
||||
|
||||
// Too deeply nested
|
||||
let path = home_dir
|
||||
.join("repo")
|
||||
.join(".agents")
|
||||
.join("skills")
|
||||
.join("a")
|
||||
.join("b")
|
||||
.join("SKILL.md");
|
||||
assert_eq!(extract_skill_parent_directory(&path).ok(), None);
|
||||
|
||||
// Not in a skills directory
|
||||
let path = home_dir.join("repo").join("src").join("SKILL.md");
|
||||
assert_eq!(extract_skill_parent_directory(&path).ok(), None);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests for is_home_skill_directory
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn is_home_skill_directory_true_for_home_skill_dir() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
|
||||
// ~/.agents/skills/skill-name
|
||||
let path = home_dir.join(".agents").join("skills").join("my-skill");
|
||||
assert!(is_home_skill_directory(&path));
|
||||
|
||||
// ~/.claude/skills/skill-name
|
||||
let path = home_dir.join(".claude").join("skills").join("test-skill");
|
||||
assert!(is_home_skill_directory(&path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_home_skill_directory_false_for_project_skill_dir() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
|
||||
// ~/repos/project/.agents/skills/my-skill is a project skill dir, not home
|
||||
let path = home_dir
|
||||
.join("repos")
|
||||
.join("project")
|
||||
.join(".agents")
|
||||
.join("skills")
|
||||
.join("my-skill");
|
||||
assert!(!is_home_skill_directory(&path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_home_skill_directory_false_for_provider_path_itself() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
|
||||
// ~/.agents/skills is the provider path, not a skill directory
|
||||
let path = home_dir.join(".agents").join("skills");
|
||||
assert!(!is_home_skill_directory(&path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_home_skill_directory_false_for_arbitrary_path() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
|
||||
let path = home_dir.join("some").join("random").join("dir");
|
||||
assert!(!is_home_skill_directory(&path));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests for is_home_provider_path
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn is_home_provider_path_true_for_known_providers() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
|
||||
let path = home_dir.join(".agents").join("skills");
|
||||
assert!(is_home_provider_path(&path));
|
||||
|
||||
if let Some(path) = warp_core::paths::warp_home_skills_dir() {
|
||||
assert!(is_home_provider_path(&path));
|
||||
}
|
||||
|
||||
let path = home_dir.join(".claude").join("skills");
|
||||
assert!(is_home_provider_path(&path));
|
||||
|
||||
let path = home_dir.join(".codex").join("skills");
|
||||
assert!(is_home_provider_path(&path));
|
||||
|
||||
let path = home_dir.join(".cursor").join("skills");
|
||||
assert!(is_home_provider_path(&path));
|
||||
|
||||
let path = home_dir.join(".gemini").join("skills");
|
||||
assert!(is_home_provider_path(&path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_skill_parent_directory_returns_home_dir_for_warp_home_skill() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
let Some(warp_home_skills_dir) = warp_core::paths::warp_home_skills_dir() else {
|
||||
eprintln!("Skipping test: Warp home skills directory not available");
|
||||
return;
|
||||
};
|
||||
|
||||
let skill_path = warp_home_skills_dir.join("test-skill").join("SKILL.md");
|
||||
let result = extract_skill_parent_directory(&skill_path);
|
||||
assert_eq!(result.ok(), Some(home_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_home_provider_path_false_for_unknown_provider() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
|
||||
let path = home_dir.join(".garbage").join("skills");
|
||||
assert!(!is_home_provider_path(&path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_home_provider_path_false_for_project_provider() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
|
||||
// Project-level provider path, not home
|
||||
let path = home_dir
|
||||
.join("repos")
|
||||
.join("project")
|
||||
.join(".agents")
|
||||
.join("skills");
|
||||
assert!(!is_home_provider_path(&path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_home_provider_path_false_for_partial_path() {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
eprintln!("Skipping test: home directory not available");
|
||||
return;
|
||||
};
|
||||
|
||||
// Just the provider directory, not the skills subdirectory
|
||||
let path = home_dir.join(".agents");
|
||||
assert!(!is_home_provider_path(&path));
|
||||
|
||||
// Just home
|
||||
assert!(!is_home_provider_path(&home_dir));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests for find_skill_directories_in_tree
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn find_skill_directories_in_tree_finds_root_skills() {
|
||||
VirtualFS::test("find_root_skills", |dirs, mut vfs| {
|
||||
let repo = dirs.tests().join("repo");
|
||||
|
||||
vfs.mkdir("repo/.agents/skills/root-skill-1")
|
||||
.mkdir("repo/.claude/skills/root-skill-2")
|
||||
.with_files(vec![
|
||||
Stub::FileWithContent(
|
||||
"repo/.agents/skills/root-skill-1/SKILL.md",
|
||||
"---\nname: root-skill-1\ndescription: test\n---\n# root-skill-1",
|
||||
),
|
||||
Stub::FileWithContent(
|
||||
"repo/.claude/skills/root-skill-2/SKILL.md",
|
||||
"---\nname: root-skill-2\ndescription: test\n---\n# root-skill-2",
|
||||
),
|
||||
]);
|
||||
|
||||
let skill1_file = Entry::File(FileMetadata::new(
|
||||
repo.join(".agents/skills/root-skill-1/SKILL.md"),
|
||||
false,
|
||||
));
|
||||
let skill1_dir = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo.join(".agents/skills/root-skill-1"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![skill1_file],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
let warp_skills = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo.join(".agents/skills"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![skill1_dir],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
let warp_dir = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo.join(".agents"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![warp_skills],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
|
||||
let skill2_file = Entry::File(FileMetadata::new(
|
||||
repo.join(".claude/skills/root-skill-2/SKILL.md"),
|
||||
false,
|
||||
));
|
||||
let skill2_dir = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo.join(".claude/skills/root-skill-2"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![skill2_file],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
let claude_skills = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo.join(".claude/skills"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![skill2_dir],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
let claude_dir = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo.join(".claude"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![claude_skills],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
|
||||
let root = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(&repo).unwrap(),
|
||||
children: vec![warp_dir, claude_dir],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let watcher = app.add_singleton_model(DirectoryWatcher::new);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
let repo_handle = watcher.update(&mut app, |w, ctx| {
|
||||
w.add_directory(
|
||||
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo)
|
||||
.unwrap(),
|
||||
ctx,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
let state = FileTreeState::new(root, vec![], Some(repo_handle));
|
||||
|
||||
let model_handle = app.add_singleton_model(RepoMetadataModel::new);
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
let key =
|
||||
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo)
|
||||
.unwrap();
|
||||
model.insert_test_state(key, state, ctx);
|
||||
});
|
||||
|
||||
model_handle.read(&app, |model, ctx| {
|
||||
let skill_dirs = find_skill_directories_in_tree(&repo, model, ctx);
|
||||
assert_eq!(skill_dirs.len(), 2);
|
||||
assert!(skill_dirs.contains(&repo.join(".agents/skills")));
|
||||
assert!(skill_dirs.contains(&repo.join(".claude/skills")));
|
||||
|
||||
let skills = read_skills_from_directories(skill_dirs);
|
||||
assert_eq!(skills.len(), 2);
|
||||
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
|
||||
assert!(names.contains(&"root-skill-1"));
|
||||
assert!(names.contains(&"root-skill-2"));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_skill_directories_in_tree_finds_subdirectory_skills() {
|
||||
VirtualFS::test("find_subdir_skills", |dirs, mut vfs| {
|
||||
let repo = dirs.tests().join("repo");
|
||||
|
||||
vfs.mkdir("repo/.agents/skills/root-skill")
|
||||
.mkdir("repo/packages/frontend/.agents/skills/frontend-skill")
|
||||
.with_files(vec![
|
||||
Stub::FileWithContent(
|
||||
"repo/.agents/skills/root-skill/SKILL.md",
|
||||
"---\nname: root-skill\ndescription: test\n---\n# root-skill",
|
||||
),
|
||||
Stub::FileWithContent(
|
||||
"repo/packages/frontend/.agents/skills/frontend-skill/SKILL.md",
|
||||
"---\nname: frontend-skill\ndescription: test\n---\n# frontend-skill",
|
||||
),
|
||||
]);
|
||||
|
||||
let root_skill_file = Entry::File(FileMetadata::new(
|
||||
repo.join(".agents/skills/root-skill/SKILL.md"),
|
||||
false,
|
||||
));
|
||||
let root_skill_dir = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo.join(".agents/skills/root-skill"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![root_skill_file],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
let root_warp_skills = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo.join(".agents/skills"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![root_skill_dir],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
let root_warp = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo.join(".agents"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![root_warp_skills],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
|
||||
let frontend_skill_file = Entry::File(FileMetadata::new(
|
||||
repo.join("packages/frontend/.agents/skills/frontend-skill/SKILL.md"),
|
||||
false,
|
||||
));
|
||||
let frontend_skill_dir = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo.join("packages/frontend/.agents/skills/frontend-skill"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![frontend_skill_file],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
let frontend_warp_skills = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo.join("packages/frontend/.agents/skills"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![frontend_skill_dir],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
let frontend_warp = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo.join("packages/frontend/.agents"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![frontend_warp_skills],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
let frontend = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo.join("packages/frontend"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![frontend_warp],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
let packages = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo.join("packages"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![frontend],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
|
||||
let root = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(&repo).unwrap(),
|
||||
children: vec![root_warp, packages],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let watcher = app.add_singleton_model(DirectoryWatcher::new);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
let repo_handle = watcher.update(&mut app, |w, ctx| {
|
||||
w.add_directory(
|
||||
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo)
|
||||
.unwrap(),
|
||||
ctx,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
let state = FileTreeState::new(root, vec![], Some(repo_handle));
|
||||
|
||||
let model_handle = app.add_singleton_model(RepoMetadataModel::new);
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
let key =
|
||||
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo)
|
||||
.unwrap();
|
||||
model.insert_test_state(key, state, ctx);
|
||||
});
|
||||
|
||||
model_handle.read(&app, |model, ctx| {
|
||||
let skill_dirs = find_skill_directories_in_tree(&repo, model, ctx);
|
||||
assert_eq!(skill_dirs.len(), 2);
|
||||
assert!(skill_dirs.contains(&repo.join(".agents/skills")));
|
||||
assert!(skill_dirs.contains(&repo.join("packages/frontend/.agents/skills")));
|
||||
|
||||
let skills = read_skills_from_directories(skill_dirs);
|
||||
assert_eq!(skills.len(), 2);
|
||||
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
|
||||
assert!(names.contains(&"root-skill"));
|
||||
assert!(names.contains(&"frontend-skill"));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_skill_directories_in_tree_empty_repo() {
|
||||
VirtualFS::test("find_skills_empty", |dirs, mut vfs| {
|
||||
let repo = dirs.tests().join("repo");
|
||||
vfs.mkdir("repo/src");
|
||||
|
||||
let src = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(&repo.join("src"))
|
||||
.unwrap(),
|
||||
children: vec![],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
let root = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(&repo).unwrap(),
|
||||
children: vec![src],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let watcher = app.add_singleton_model(DirectoryWatcher::new);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
let repo_handle = watcher.update(&mut app, |w, ctx| {
|
||||
w.add_directory(
|
||||
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo)
|
||||
.unwrap(),
|
||||
ctx,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
let state = FileTreeState::new(root, vec![], Some(repo_handle));
|
||||
|
||||
let model_handle = app.add_singleton_model(RepoMetadataModel::new);
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
let key =
|
||||
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo)
|
||||
.unwrap();
|
||||
model.insert_test_state(key, state, ctx);
|
||||
});
|
||||
|
||||
model_handle.read(&app, |model, ctx| {
|
||||
let skill_dirs = find_skill_directories_in_tree(&repo, model, ctx);
|
||||
assert!(skill_dirs.is_empty());
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use ai::skills::{ParsedSkill, SkillProvider, SkillReference, SkillScope};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warp_core::ui::icons::Icon;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub struct SkillDescriptor {
|
||||
pub reference: SkillReference,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// The scope of the skill (home directory vs project directory).
|
||||
pub scope: SkillScope,
|
||||
/// The provider/origin of the skill (Claude, Codex, or Warp).
|
||||
/// None if the skill path didn't match a known provider directory.
|
||||
pub provider: SkillProvider,
|
||||
/// Override icon for this skill. When set, rendering code should use this
|
||||
/// instead of deriving the icon from the provider.
|
||||
#[serde(skip)]
|
||||
pub icon_override: Option<Icon>,
|
||||
}
|
||||
|
||||
impl SkillDescriptor {
|
||||
/// Returns whether this skill is from a project directory (vs home directory).
|
||||
pub fn is_project_skill(&self) -> bool {
|
||||
self.scope == SkillScope::Project
|
||||
}
|
||||
|
||||
pub fn new_bundled(id: String, skill: ParsedSkill, icon: Icon) -> Self {
|
||||
Self {
|
||||
provider: SkillProvider::Warp,
|
||||
scope: SkillScope::Bundled,
|
||||
reference: SkillReference::BundledSkillId(id),
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
icon_override: Some(icon),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParsedSkill> for SkillDescriptor {
|
||||
fn from(skill: ParsedSkill) -> Self {
|
||||
Self {
|
||||
provider: skill.provider,
|
||||
scope: skill.scope,
|
||||
reference: SkillReference::Path(skill.path),
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
icon_override: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
mod telemetry;
|
||||
pub use telemetry::{SkillOpenOrigin, SkillTelemetryEvent};
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(not(feature = "local_fs"))] {
|
||||
mod dummy_skill_manager;
|
||||
pub use dummy_skill_manager::SkillManager;
|
||||
}
|
||||
}
|
||||
|
||||
pub use ai::skills::SkillReference;
|
||||
|
||||
mod listed_skill;
|
||||
pub use listed_skill::SkillDescriptor;
|
||||
|
||||
mod skill_utils;
|
||||
pub use skill_utils::{
|
||||
icon_override_for_skill_name, list_skills_if_changed, render_skill_button,
|
||||
skill_path_from_file_path,
|
||||
};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod resolve_skill_spec;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub use resolve_skill_spec::{
|
||||
clone_repo_for_skill, resolve_skill_spec, ResolveSkillError, ResolvedSkill,
|
||||
};
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
mod skill_manager;
|
||||
pub use skill_manager::{SkillManager, SkillWatcher};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
//! Skill resolution for agent runs.
|
||||
//!
|
||||
//! This module exists primarily for `warp agent run --skill ...` (and related flows) where we need to
|
||||
//! resolve a CLI-provided `--skill` specifier (`SkillSpec`) into a concrete `SKILL.md` file and its
|
||||
//! parsed instruction body.
|
||||
//!
|
||||
//! While `SkillManager` maintains a cached view of known skills and can list skills "in scope",
|
||||
//! agent runs need a single-shot resolver that:
|
||||
//! - Works when invoked from directories *above* any detected repos (ambient/root-scope case).
|
||||
//! - Supports qualified specs (`repo:skill` and `org/repo:skill`) and returns good errors on ambiguity.
|
||||
//! - Applies consistent skill-directory precedence (e.g. `.claude/` vs `.codex/`, etc.).
|
||||
//! - Falls back to scanning disk when the manager cache has not warmed yet.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ai::skills::{
|
||||
home_skills_path, parse_skill, ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS,
|
||||
};
|
||||
use command::blocking::Command;
|
||||
use command::r#async::Command as AsyncCommand;
|
||||
use warp_cli::skill::SkillSpec;
|
||||
use warpui::AppContext;
|
||||
use warpui::SingletonEntity as _;
|
||||
|
||||
use super::SkillManager;
|
||||
use crate::warp_managed_paths_watcher::warp_managed_skill_dirs;
|
||||
|
||||
const SKILL_FILE_NAME: &str = "SKILL.md";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedSkill {
|
||||
pub skill_path: PathBuf,
|
||||
pub name: String,
|
||||
pub instructions: String,
|
||||
/// The full parsed skill, used for proto conversion when sending to server.
|
||||
pub parsed_skill: ParsedSkill,
|
||||
}
|
||||
|
||||
fn resolve_from_skill_dirs_by_directory_scan(
|
||||
spec: &SkillSpec,
|
||||
skill_dirs: impl IntoIterator<Item = PathBuf>,
|
||||
) -> Result<Option<ResolvedSkill>, ResolveSkillError> {
|
||||
if spec.is_full_path() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
for skill_dir in skill_dirs {
|
||||
let path = skill_dir.join(&spec.skill_identifier).join(SKILL_FILE_NAME);
|
||||
|
||||
if path.exists() {
|
||||
let parsed = parse_skill(&path).map_err(|err| ResolveSkillError::ParseFailed {
|
||||
path: path.clone(),
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
|
||||
return Ok(Some(to_resolved_skill(path, parsed)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn home_skill_dirs_for_resolution() -> Vec<PathBuf> {
|
||||
let mut skill_dirs = Vec::new();
|
||||
for provider in SKILL_PROVIDER_DEFINITIONS.iter() {
|
||||
if provider.provider == SkillProvider::Warp {
|
||||
for dir in warp_managed_skill_dirs() {
|
||||
push_unique_path(&mut skill_dirs, dir);
|
||||
}
|
||||
} else if let Some(dir) = home_skills_path(provider.provider) {
|
||||
push_unique_path(&mut skill_dirs, dir);
|
||||
}
|
||||
}
|
||||
skill_dirs
|
||||
}
|
||||
|
||||
fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
|
||||
if !paths.iter().any(|existing| existing == &path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ResolveSkillError {
|
||||
#[error("Skill '{skill}' not found")]
|
||||
NotFound { skill: String },
|
||||
#[error("Repository '{repo}' not found")]
|
||||
RepoNotFound { repo: String },
|
||||
#[error("Skill '{skill}' is ambiguous; specify as repo:skill_name")]
|
||||
Ambiguous {
|
||||
skill: String,
|
||||
candidates: Vec<PathBuf>,
|
||||
},
|
||||
#[error("Repository '{repo}' found but belongs to org '{found}', expected '{expected}'")]
|
||||
OrgMismatch {
|
||||
repo: String,
|
||||
expected: String,
|
||||
found: String,
|
||||
},
|
||||
#[error("Failed to parse skill file {path}: {message}")]
|
||||
ParseFailed { path: PathBuf, message: String },
|
||||
#[error("Failed to clone repository '{org}/{repo}': {message}")]
|
||||
CloneFailed {
|
||||
org: String,
|
||||
repo: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Resolve a `SkillSpec` (from the `--skill` CLI arg) into a concrete SKILL.md file.
|
||||
///
|
||||
/// Resolution flow:
|
||||
/// - If the spec is repo-qualified (`repo:skill` or `org/repo:skill`):
|
||||
/// - Find candidate repo roots from `SkillManager` (fallback: a direct child of `working_dir`).
|
||||
/// - Optionally filter by `org` using the repo's `origin` remote when available.
|
||||
/// - For each candidate repo root, try to resolve the skill (cache-first, then disk scan).
|
||||
/// - If the spec is unqualified (`skill`):
|
||||
/// 1. Check cached home-directory skills.
|
||||
/// 2. Scan home/global skill directories directly for cold-start resolution.
|
||||
/// 3. Try the current repo root (if `working_dir` is inside a detected repo).
|
||||
/// 4. Otherwise, search across repos *under* `working_dir` (ambient/root-scope support).
|
||||
/// - If multiple matches exist, return an ambiguity error.
|
||||
/// 5. Finally, fall back to scanning the filesystem relative to `working_dir`.
|
||||
///
|
||||
/// Once a SKILL.md is selected, we return the parsed instruction body (front matter stripped).
|
||||
pub fn resolve_skill_spec(
|
||||
spec: &SkillSpec,
|
||||
working_dir: &Path,
|
||||
ctx: &AppContext,
|
||||
) -> Result<ResolvedSkill, ResolveSkillError> {
|
||||
let skill_manager = SkillManager::as_ref(ctx);
|
||||
|
||||
match &spec.repo {
|
||||
Some(repo) => resolve_repo_qualified(spec, repo, working_dir, skill_manager, ctx),
|
||||
None => resolve_unqualified(spec, working_dir, ctx, skill_manager),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone a repository from GitHub into the working directory for skill resolution.
|
||||
///
|
||||
/// Uses HTTPS format: `https://github.com/org/repo.git`
|
||||
///
|
||||
/// This is used in sandboxed environments to auto-clone repos when a fully-qualified
|
||||
/// skill spec references a repo that doesn't exist locally.
|
||||
pub async fn clone_repo_for_skill(
|
||||
org: &str,
|
||||
repo: &str,
|
||||
working_dir: &Path,
|
||||
) -> Result<(), ResolveSkillError> {
|
||||
let repo_url = format!("https://github.com/{org}/{repo}.git");
|
||||
let target_dir = working_dir.join(repo);
|
||||
|
||||
// Check if target already exists.
|
||||
if target_dir.exists() {
|
||||
if target_dir.join(".git").is_dir() {
|
||||
log::info!(
|
||||
"Target directory {} already exists and appears to be a git repo, skipping clone",
|
||||
target_dir.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
return Err(ResolveSkillError::CloneFailed {
|
||||
org: org.to_string(),
|
||||
repo: repo.to_string(),
|
||||
message: format!(
|
||||
"Target directory {} already exists but is not a git repository",
|
||||
target_dir.display()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
log::info!("Cloning {} into {}", repo_url, target_dir.display());
|
||||
log::debug!(
|
||||
"[GIT OPERATION] resolve_skill_spec.rs clone_repo_for_skill git clone {} {}",
|
||||
repo_url,
|
||||
target_dir.display()
|
||||
);
|
||||
|
||||
let output = AsyncCommand::new("git")
|
||||
.arg("clone")
|
||||
.arg(&repo_url)
|
||||
.arg(&target_dir)
|
||||
.current_dir(working_dir)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| ResolveSkillError::CloneFailed {
|
||||
org: org.to_string(),
|
||||
repo: repo.to_string(),
|
||||
message: format!("Failed to execute git clone: {e}"),
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(ResolveSkillError::CloneFailed {
|
||||
org: org.to_string(),
|
||||
repo: repo.to_string(),
|
||||
message: stderr.trim().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
log::info!("Successfully cloned {org}/{repo}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_repo_qualified(
|
||||
spec: &SkillSpec,
|
||||
repo: &str,
|
||||
working_dir: &Path,
|
||||
skill_manager: &SkillManager,
|
||||
_ctx: &AppContext,
|
||||
) -> Result<ResolvedSkill, ResolveSkillError> {
|
||||
// Find directories with skills where the directory name matches the repo.
|
||||
// This includes both repo roots and subdirectories.
|
||||
let mut candidate_repo_roots: Vec<PathBuf> = skill_manager
|
||||
.directories_with_skills()
|
||||
.into_iter()
|
||||
.filter(|dir| dir.file_name().is_some_and(|n| n == repo))
|
||||
.collect();
|
||||
|
||||
// Fallback: if we don't know about the repo yet, check a direct child directory.
|
||||
if candidate_repo_roots.is_empty() {
|
||||
let direct_child = working_dir.join(repo);
|
||||
if direct_child.is_dir() {
|
||||
candidate_repo_roots.push(direct_child);
|
||||
}
|
||||
}
|
||||
|
||||
if candidate_repo_roots.is_empty() {
|
||||
return Err(ResolveSkillError::RepoNotFound {
|
||||
repo: repo.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// If org is specified, validate it when we can determine the org.
|
||||
if let Some(expected_org) = &spec.org {
|
||||
let mut filtered = Vec::new();
|
||||
let mut first_mismatch: Option<String> = None;
|
||||
|
||||
for repo_root in &candidate_repo_roots {
|
||||
match get_git_remote_org(repo_root) {
|
||||
Some(found_org) if &found_org != expected_org => {
|
||||
if first_mismatch.is_none() {
|
||||
first_mismatch = Some(found_org);
|
||||
}
|
||||
}
|
||||
_ => filtered.push(repo_root.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
if filtered.is_empty() {
|
||||
return Err(ResolveSkillError::OrgMismatch {
|
||||
repo: repo.to_string(),
|
||||
expected: expected_org.clone(),
|
||||
found: first_mismatch.unwrap_or_else(|| "unknown".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
candidate_repo_roots = filtered;
|
||||
}
|
||||
|
||||
// Try each matching repo root in a stable order.
|
||||
candidate_repo_roots.sort();
|
||||
|
||||
for repo_root in candidate_repo_roots {
|
||||
match resolve_in_single_repo_root(spec, &repo_root, skill_manager) {
|
||||
Ok(resolved) => return Ok(resolved),
|
||||
Err(ResolveSkillError::NotFound { .. }) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
Err(ResolveSkillError::NotFound {
|
||||
skill: spec.skill_identifier.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_unqualified(
|
||||
spec: &SkillSpec,
|
||||
working_dir: &Path,
|
||||
ctx: &AppContext,
|
||||
skill_manager: &SkillManager,
|
||||
) -> Result<ResolvedSkill, ResolveSkillError> {
|
||||
// If the skill_path is a full path, skip cache lookup and go straight to disk resolution.
|
||||
// Full paths don't match skill names in the cache.
|
||||
if spec.is_full_path() {
|
||||
if let Some(resolved) = resolve_from_root_path_by_directory_scan(spec, working_dir)? {
|
||||
return Ok(resolved);
|
||||
}
|
||||
return Err(ResolveSkillError::NotFound {
|
||||
skill: spec.skill_identifier.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Get all skill paths matching the requested name from the cache.
|
||||
let all_matching_paths = skill_manager.skill_paths_by_name(&spec.skill_identifier);
|
||||
let home_dir = dirs::home_dir();
|
||||
|
||||
// Per the skills spec, home directory skills take precedence over project skills.
|
||||
// Check home directory skills first.
|
||||
let home_skill_paths = skill_manager.home_skill_paths();
|
||||
let home_matches: Vec<PathBuf> = all_matching_paths
|
||||
.iter()
|
||||
.filter(|p| home_skill_paths.contains(p))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if let Some(skill_path) = best_match_by_directory_precedence(home_matches, home_dir.as_deref())
|
||||
{
|
||||
return parsed_skill_from_manager_or_disk(skill_manager, &skill_path)
|
||||
.map(|parsed| to_resolved_skill(skill_path, parsed));
|
||||
}
|
||||
|
||||
if let Some(resolved) =
|
||||
resolve_from_skill_dirs_by_directory_scan(spec, home_skill_dirs_for_resolution())?
|
||||
{
|
||||
return Ok(resolved);
|
||||
}
|
||||
|
||||
// Next, try to scope to the current repo root (if known).
|
||||
let repo_root = repo_metadata::repositories::DetectedRepositories::as_ref(ctx)
|
||||
.get_root_for_path(working_dir);
|
||||
|
||||
if let Some(repo_root) = repo_root {
|
||||
match resolve_in_single_repo_root(spec, &repo_root, skill_manager) {
|
||||
Ok(resolved) => return Ok(resolved),
|
||||
Err(ResolveSkillError::NotFound { .. }) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
// If we're not in a known repo, try searching across repos under the working directory.
|
||||
let in_scope_matches: Vec<PathBuf> = all_matching_paths
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
// Only include project skills (not home skills) that are under working_dir
|
||||
skill_manager.skill_paths_in_scope(working_dir).contains(p)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if in_scope_matches.len() == 1 {
|
||||
let skill_path = in_scope_matches[0].clone();
|
||||
return parsed_skill_from_manager_or_disk(skill_manager, &skill_path)
|
||||
.map(|parsed| to_resolved_skill(skill_path, parsed));
|
||||
}
|
||||
|
||||
if in_scope_matches.len() > 1 {
|
||||
return Err(ResolveSkillError::Ambiguous {
|
||||
skill: spec.skill_identifier.clone(),
|
||||
candidates: in_scope_matches,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: if SkillManager hasn't cached anything yet, try resolving relative to the working dir.
|
||||
if let Some(resolved) = resolve_from_root_path_by_directory_scan(spec, working_dir)? {
|
||||
return Ok(resolved);
|
||||
}
|
||||
|
||||
Err(ResolveSkillError::NotFound {
|
||||
skill: spec.skill_identifier.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_in_single_repo_root(
|
||||
spec: &SkillSpec,
|
||||
repo_root: &Path,
|
||||
skill_manager: &SkillManager,
|
||||
) -> Result<ResolvedSkill, ResolveSkillError> {
|
||||
// If the skill_path is a full path, skip cache lookup and go straight to disk resolution.
|
||||
// Full paths don't match skill names in the cache.
|
||||
if spec.is_full_path() {
|
||||
if let Some(resolved) = resolve_from_root_path_by_directory_scan(spec, repo_root)? {
|
||||
return Ok(resolved);
|
||||
}
|
||||
return Err(ResolveSkillError::NotFound {
|
||||
skill: spec.skill_identifier.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Prefer cached skills (fast path).
|
||||
// Use the name-based cache and filter to paths within this repo root.
|
||||
let repo_skill_paths = skill_manager.skill_paths_in_scope(repo_root);
|
||||
let cached_paths: Vec<PathBuf> = skill_manager
|
||||
.skill_paths_by_name(&spec.skill_identifier)
|
||||
.into_iter()
|
||||
.filter(|p| repo_skill_paths.contains(p))
|
||||
.collect();
|
||||
|
||||
if let Some(best_path) = best_match_by_directory_precedence(cached_paths, Some(repo_root)) {
|
||||
let parsed = parsed_skill_from_manager_or_disk(skill_manager, &best_path)?;
|
||||
return Ok(to_resolved_skill(best_path, parsed));
|
||||
}
|
||||
|
||||
// Cold start fallback: check disk in precedence order.
|
||||
if let Some(resolved) = resolve_from_root_path_by_directory_scan(spec, repo_root)? {
|
||||
return Ok(resolved);
|
||||
}
|
||||
|
||||
Err(ResolveSkillError::NotFound {
|
||||
skill: spec.skill_identifier.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_from_root_path_by_directory_scan(
|
||||
spec: &SkillSpec,
|
||||
root: &Path,
|
||||
) -> Result<Option<ResolvedSkill>, ResolveSkillError> {
|
||||
// If the skill_path is a full path (contains "/" or ends with ".md"),
|
||||
// try to resolve it directly without iterating through SKILL_PROVIDER_DEFINITIONS.
|
||||
if spec.is_full_path() {
|
||||
// Reject absolute paths to prevent escaping the root directory
|
||||
let skill_path = Path::new(&spec.skill_identifier);
|
||||
if skill_path.is_absolute() {
|
||||
return Err(ResolveSkillError::NotFound {
|
||||
skill: spec.skill_identifier.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let path = root.join(&spec.skill_identifier);
|
||||
if path.exists() {
|
||||
let parsed = parse_skill(&path).map_err(|err| ResolveSkillError::ParseFailed {
|
||||
path: path.clone(),
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
|
||||
return Ok(Some(to_resolved_skill(path, parsed)));
|
||||
}
|
||||
// If full path doesn't exist, return None (don't fall through to directory scan)
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// For simple skill names, iterate through SKILL_PROVIDER_DEFINITIONS in precedence order.
|
||||
for provider in SKILL_PROVIDER_DEFINITIONS.iter() {
|
||||
let path = root
|
||||
.join(&provider.skills_path)
|
||||
.join(&spec.skill_identifier)
|
||||
.join(SKILL_FILE_NAME);
|
||||
|
||||
if path.exists() {
|
||||
let parsed = parse_skill(&path).map_err(|err| ResolveSkillError::ParseFailed {
|
||||
path: path.clone(),
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
|
||||
return Ok(Some(to_resolved_skill(path, parsed)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn parsed_skill_from_manager_or_disk(
|
||||
skill_manager: &SkillManager,
|
||||
skill_path: &Path,
|
||||
) -> Result<ParsedSkill, ResolveSkillError> {
|
||||
if let Some(parsed) = skill_manager.skill_by_path(skill_path).cloned() {
|
||||
return Ok(parsed);
|
||||
}
|
||||
|
||||
parse_skill(skill_path).map_err(|err| ResolveSkillError::ParseFailed {
|
||||
path: skill_path.to_path_buf(),
|
||||
message: err.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn to_resolved_skill(skill_path: PathBuf, parsed: ParsedSkill) -> ResolvedSkill {
|
||||
let instructions = instructions_body(&parsed);
|
||||
ResolvedSkill {
|
||||
name: parsed.name.clone(),
|
||||
instructions,
|
||||
skill_path,
|
||||
parsed_skill: parsed,
|
||||
}
|
||||
}
|
||||
|
||||
fn instructions_body(skill: &ParsedSkill) -> String {
|
||||
let Some(line_range) = &skill.line_range else {
|
||||
return skill.content.clone();
|
||||
};
|
||||
|
||||
// line_range is 1-indexed, end-exclusive.
|
||||
let start = line_range.start.saturating_sub(1);
|
||||
let end = line_range.end.saturating_sub(1);
|
||||
|
||||
let lines: Vec<&str> = skill.content.lines().collect();
|
||||
if start >= lines.len() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let end = end.min(lines.len());
|
||||
lines[start..end].join("\n").trim().to_string()
|
||||
}
|
||||
|
||||
fn best_match_by_directory_precedence(
|
||||
mut matches: Vec<PathBuf>,
|
||||
root: Option<&Path>,
|
||||
) -> Option<PathBuf> {
|
||||
if matches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// If we can't determine the root, fall back to a stable path sort.
|
||||
let Some(root) = root else {
|
||||
matches.sort();
|
||||
return matches.into_iter().next();
|
||||
};
|
||||
|
||||
matches.sort_by(|a, b| {
|
||||
let a_rank = directory_precedence_rank(root, a);
|
||||
let b_rank = directory_precedence_rank(root, b);
|
||||
|
||||
a_rank.cmp(&b_rank).then_with(|| a.cmp(b))
|
||||
});
|
||||
|
||||
matches.into_iter().next()
|
||||
}
|
||||
|
||||
fn directory_precedence_rank(root: &Path, skill_path: &Path) -> usize {
|
||||
for (idx, provider) in SKILL_PROVIDER_DEFINITIONS.iter().enumerate() {
|
||||
if skill_path.starts_with(root.join(&provider.skills_path)) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
|
||||
SKILL_PROVIDER_DEFINITIONS.len()
|
||||
}
|
||||
|
||||
fn get_git_remote_org(repo_path: &Path) -> Option<String> {
|
||||
log::debug!(
|
||||
"[GIT OPERATION] resolve_skill_spec.rs get_git_remote_org git remote get-url origin"
|
||||
);
|
||||
let output = Command::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let url = String::from_utf8(output.stdout).ok()?.trim().to_string();
|
||||
parse_org_from_git_url(&url)
|
||||
}
|
||||
|
||||
fn parse_org_from_git_url(url: &str) -> Option<String> {
|
||||
// SSH format: git@github.com:org/repo.git
|
||||
if let Some(rest) = url.strip_prefix("git@") {
|
||||
if let Some(path_start) = rest.find(':') {
|
||||
let path = &rest[path_start + 1..];
|
||||
if let Some(slash_pos) = path.find('/') {
|
||||
return Some(path[..slash_pos].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPS format: https://github.com/org/repo.git
|
||||
if url.starts_with("https://") || url.starts_with("http://") {
|
||||
let parts: Vec<&str> = url.split('/').collect();
|
||||
// https://github.com/org/repo.git
|
||||
if parts.len() >= 4 {
|
||||
return Some(parts[3].to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "resolve_skill_spec_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,225 @@
|
||||
use std::{fs, path::Path};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use warp_cli::skill::SkillSpec;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn write_skill_file(path: &Path, name: &str, description: &str, body: &str) -> Result<()> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.with_context(|| format!("Missing parent for {}", path.display()))?;
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create dir {}", parent.display()))?;
|
||||
|
||||
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n");
|
||||
|
||||
fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_from_skill_dirs_by_directory_scan_resolves_home_skill_dir() -> Result<()> {
|
||||
let temp_dir = tempfile::TempDir::new().context("Failed to create temp dir")?;
|
||||
let skill_dir = temp_dir.path().join(".warp").join("skills");
|
||||
let skill_path = skill_dir.join("my-skill").join("SKILL.md");
|
||||
|
||||
write_skill_file(
|
||||
&skill_path,
|
||||
"my-skill",
|
||||
"desc",
|
||||
"# Global Warp skill\n\nUse this one.",
|
||||
)?;
|
||||
|
||||
let spec = SkillSpec::without_repo("my-skill".to_string());
|
||||
let resolved = resolve_from_skill_dirs_by_directory_scan(&spec, [skill_dir])?
|
||||
.context("Expected to resolve skill from explicit home skill dir")?;
|
||||
|
||||
assert_eq!(resolved.skill_path, skill_path);
|
||||
assert!(resolved.instructions.contains("Global Warp skill"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_from_root_path_by_directory_scan_respects_directory_precedence() -> Result<()> {
|
||||
let temp_dir = tempfile::TempDir::new().context("Failed to create temp dir")?;
|
||||
let root = temp_dir.path();
|
||||
|
||||
let spec = SkillSpec::without_repo("my-skill".to_string());
|
||||
let agents_skill = root.join(".agents/skills/my-skill/SKILL.md");
|
||||
let warp_skill = root.join(".warp/skills/my-skill/SKILL.md");
|
||||
|
||||
let claude_skill = root.join(".claude/skills/my-skill/SKILL.md");
|
||||
let codex_skill = root.join(".codex/skills/my-skill/SKILL.md");
|
||||
|
||||
write_skill_file(
|
||||
&agents_skill,
|
||||
"my-skill",
|
||||
"desc",
|
||||
"# Agents version\n\nUse this one.",
|
||||
)?;
|
||||
write_skill_file(
|
||||
&warp_skill,
|
||||
"my-skill",
|
||||
"desc",
|
||||
"# Warp version\n\nDo not pick this when .agents exists.",
|
||||
)?;
|
||||
write_skill_file(
|
||||
&claude_skill,
|
||||
"my-skill",
|
||||
"desc",
|
||||
"# Claude version\n\nDo not pick this when .warp exists.",
|
||||
)?;
|
||||
write_skill_file(
|
||||
&codex_skill,
|
||||
"my-skill",
|
||||
"desc",
|
||||
"# Codex version\n\nDo not pick this when .claude exists.",
|
||||
)?;
|
||||
|
||||
let resolved = resolve_from_root_path_by_directory_scan(&spec, root)?
|
||||
.context("Expected to resolve skill via directory scan")?;
|
||||
|
||||
assert_eq!(resolved.skill_path, agents_skill);
|
||||
assert!(resolved.instructions.contains("Agents version"));
|
||||
assert!(!resolved.instructions.contains("Warp version"));
|
||||
assert!(!resolved.instructions.contains("Claude version"));
|
||||
assert!(!resolved.instructions.contains("Codex version"));
|
||||
assert!(!resolved.instructions.contains("name:"));
|
||||
assert!(!resolved.instructions.contains("description:"));
|
||||
assert!(!resolved.instructions.contains("---"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instructions_body_strips_front_matter_using_line_range() -> Result<()> {
|
||||
let temp_dir = tempfile::TempDir::new().context("Failed to create temp dir")?;
|
||||
let skill_path = temp_dir.path().join(".agents/skills/my-skill/SKILL.md");
|
||||
|
||||
write_skill_file(
|
||||
&skill_path,
|
||||
"my-skill",
|
||||
"desc",
|
||||
"# Title\n\n## Instructions\nDo the thing.",
|
||||
)?;
|
||||
|
||||
let parsed = ai::skills::parse_skill(&skill_path).context("Failed to parse skill")?;
|
||||
let body = instructions_body(&parsed);
|
||||
|
||||
assert!(body.contains("# Title"));
|
||||
assert!(body.contains("## Instructions"));
|
||||
assert!(body.contains("Do the thing."));
|
||||
assert!(!body.contains("name:"));
|
||||
assert!(!body.contains("description:"));
|
||||
assert!(!body.contains("---"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_org_from_git_url_supports_ssh_and_https() {
|
||||
assert_eq!(
|
||||
parse_org_from_git_url("git@github.com:warpdotdev/warp-internal.git"),
|
||||
Some("warpdotdev".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_org_from_git_url("https://github.com/warpdotdev/warp-internal.git"),
|
||||
Some("warpdotdev".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_with_full_path_skips_directory_precedence() -> Result<()> {
|
||||
let temp_dir = tempfile::TempDir::new().context("Failed to create temp dir")?;
|
||||
let root = temp_dir.path();
|
||||
|
||||
// Create a skill in .claude/skills directory
|
||||
let claude_skill = root.join(".claude/skills/my-skill/SKILL.md");
|
||||
write_skill_file(
|
||||
&claude_skill,
|
||||
"my-skill",
|
||||
"desc",
|
||||
"# Claude version\n\nThis is in .claude directory.",
|
||||
)?;
|
||||
|
||||
// Create a skill in .agents/skills directory (higher precedence)
|
||||
let agents_skill = root.join(".agents/skills/my-skill/SKILL.md");
|
||||
write_skill_file(
|
||||
&agents_skill,
|
||||
"my-skill",
|
||||
"desc",
|
||||
"# Agents version\n\nThis is in .agents directory.",
|
||||
)?;
|
||||
|
||||
// Resolve using full path to .claude skill - should get .claude version, not .agents
|
||||
// (even though .agents has higher precedence when using simple names)
|
||||
let spec = SkillSpec::without_repo(".claude/skills/my-skill/SKILL.md".to_string());
|
||||
let resolved = resolve_from_root_path_by_directory_scan(&spec, root)?
|
||||
.context("Expected to resolve skill via full path")?;
|
||||
|
||||
assert_eq!(resolved.skill_path, claude_skill);
|
||||
assert!(resolved.instructions.contains("Claude version"));
|
||||
assert!(!resolved.instructions.contains("Agents version"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_with_full_path_returns_none_if_not_found() -> Result<()> {
|
||||
let temp_dir = tempfile::TempDir::new().context("Failed to create temp dir")?;
|
||||
let root = temp_dir.path();
|
||||
|
||||
// Try to resolve a full path that doesn't exist
|
||||
let spec = SkillSpec::without_repo(".claude/skills/nonexistent/SKILL.md".to_string());
|
||||
let resolved = resolve_from_root_path_by_directory_scan(&spec, root)?;
|
||||
|
||||
assert!(resolved.is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_simple_name_uses_directory_precedence() -> Result<()> {
|
||||
let temp_dir = tempfile::TempDir::new().context("Failed to create temp dir")?;
|
||||
let root = temp_dir.path();
|
||||
|
||||
// Create skills with same name in different directories
|
||||
// Note: .agents/skills has highest precedence, followed by .warp, then .claude.
|
||||
let agents_skill = root.join(".agents/skills/my-skill/SKILL.md");
|
||||
write_skill_file(
|
||||
&agents_skill,
|
||||
"my-skill",
|
||||
"desc",
|
||||
"# Agents version\n\nThis should be picked by precedence.",
|
||||
)?;
|
||||
|
||||
let warp_skill = root.join(".warp/skills/my-skill/SKILL.md");
|
||||
write_skill_file(
|
||||
&warp_skill,
|
||||
"my-skill",
|
||||
"desc",
|
||||
"# Warp version\n\nThis should lose to .agents but beat .claude.",
|
||||
)?;
|
||||
|
||||
let claude_skill = root.join(".claude/skills/my-skill/SKILL.md");
|
||||
write_skill_file(
|
||||
&claude_skill,
|
||||
"my-skill",
|
||||
"desc",
|
||||
"# Claude version\n\nThis should not be picked.",
|
||||
)?;
|
||||
|
||||
// Resolve using simple name - should get .agents version due to precedence
|
||||
let spec = SkillSpec::without_repo("my-skill".to_string());
|
||||
let resolved = resolve_from_root_path_by_directory_scan(&spec, root)?
|
||||
.context("Expected to resolve skill by name")?;
|
||||
assert_eq!(resolved.skill_path, agents_skill);
|
||||
assert!(resolved.instructions.contains("Agents version"));
|
||||
assert!(!resolved.instructions.contains("Warp version"));
|
||||
assert!(!resolved.instructions.contains("Claude version"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
#[path = "file_watchers/mod.rs"]
|
||||
mod file_watchers;
|
||||
use crate::ai::mcp::{McpIntegration, TemplatableMCPServerManager};
|
||||
pub use file_watchers::{extract_skill_parent_directory, SkillWatcher, SkillWatcherEvent};
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::settings::user_preferences_toml_file_path;
|
||||
|
||||
use super::SkillDescriptor;
|
||||
use crate::ai::skills::skill_utils::unique_skills;
|
||||
use ai::skills::{
|
||||
get_provider_for_path, parse_bundled_skill, provider_rank, ParsedSkill, SkillProvider,
|
||||
SkillReference,
|
||||
};
|
||||
use warp_core::{
|
||||
channel::ChannelState, features::FeatureFlag, report_error, safe_warn, ui::icons::Icon,
|
||||
};
|
||||
use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
/// Activation condition for a bundled skill.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BundledSkillActivation {
|
||||
/// Always active.
|
||||
Always,
|
||||
/// Active only when a specific MCP server is running.
|
||||
RequiresMcp(McpIntegration),
|
||||
/// Active only when a specific file exists on disk.
|
||||
RequiresFile(PathBuf),
|
||||
}
|
||||
|
||||
impl BundledSkillActivation {
|
||||
pub fn is_enabled(&self, ctx: &AppContext) -> bool {
|
||||
match self {
|
||||
Self::Always => true,
|
||||
Self::RequiresMcp(integration) => {
|
||||
TemplatableMCPServerManager::as_ref(ctx).is_mcp_server_running(*integration)
|
||||
}
|
||||
Self::RequiresFile(path) => path.exists(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bundled skill with its activation condition and icon.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BundledSkill {
|
||||
pub skill: ParsedSkill,
|
||||
pub activation: BundledSkillActivation,
|
||||
pub icon: Icon,
|
||||
}
|
||||
|
||||
pub struct SkillManager {
|
||||
/// Maps a directory path to the set of skill file paths defined in that directory.
|
||||
///
|
||||
/// The key is the directory containing the `.agents/skills/` (or similar provider) folder,
|
||||
/// not the skills folder itself.
|
||||
///
|
||||
/// Example: For a skill at `/repo/frontend/.agents/skills/deploy/SKILL.md`:
|
||||
/// - Key: `/repo/frontend`
|
||||
/// - Value (in the set): `/repo/frontend/.agents/skills/deploy/SKILL.md`
|
||||
///
|
||||
/// NOT:
|
||||
/// - Key: `/repo/frontend/.agents/skills`
|
||||
directory_skills: HashMap<PathBuf, HashSet<PathBuf>>,
|
||||
skills_by_path: HashMap<PathBuf, ParsedSkill>,
|
||||
/// Reverse lookup: skill name → set of paths with that name.
|
||||
/// This allows efficient lookup by skill name without scanning all paths.
|
||||
skills_by_name: HashMap<String, HashSet<PathBuf>>,
|
||||
/// Skills bundled into Warp, each with activation condition and icon.
|
||||
bundled_skills: HashMap<String, BundledSkill>,
|
||||
/// When true, all skills in `directory_skills` are in scope regardless of
|
||||
/// the current working directory. Set by `AgentDriver` when a cloud
|
||||
/// environment with configured repos is active, so the agent sees every
|
||||
/// skill from every cloned repo.
|
||||
is_cloud_environment: bool,
|
||||
#[allow(dead_code)]
|
||||
skill_watcher: ModelHandle<SkillWatcher>, // Can't remove this or it'll get cleaned up after new()
|
||||
}
|
||||
|
||||
impl SkillManager {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
let (skill_watcher_tx, skill_watcher_rx) = async_channel::unbounded();
|
||||
|
||||
ctx.spawn_stream_local(
|
||||
skill_watcher_rx,
|
||||
|me, message, _ctx| {
|
||||
me.handle_skill_watcher_event(message);
|
||||
},
|
||||
|_, _| {}, // No cleanup needed when stream ends
|
||||
);
|
||||
|
||||
// Create skill watcher
|
||||
let skill_watcher = ctx.add_model(|ctx| SkillWatcher::new(ctx, skill_watcher_tx));
|
||||
|
||||
if FeatureFlag::BundledSkills.is_enabled() {
|
||||
ctx.spawn(Self::load_bundled_skills(), |me, result, _| {
|
||||
me.bundled_skills = result;
|
||||
});
|
||||
ctx.spawn(Self::load_figma_skills(), |me, figma_skills, _| {
|
||||
me.bundled_skills.extend(figma_skills);
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
directory_skills: HashMap::new(),
|
||||
skills_by_path: HashMap::new(),
|
||||
skills_by_name: HashMap::new(),
|
||||
bundled_skills: HashMap::new(),
|
||||
is_cloud_environment: false,
|
||||
skill_watcher,
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks this manager as running in a cloud environment, enabling all
|
||||
/// directory skills to be in scope regardless of the current working directory.
|
||||
pub fn set_cloud_environment(&mut self, value: bool) {
|
||||
self.is_cloud_environment = value;
|
||||
}
|
||||
|
||||
/// Returns skills available for the given working directory.
|
||||
pub fn get_skills_for_working_directory(
|
||||
&self,
|
||||
working_directory: Option<&Path>,
|
||||
ctx: &AppContext,
|
||||
) -> Vec<SkillDescriptor> {
|
||||
// Collect skill paths as (dir_path, skill_path) tuples for later deduplication.
|
||||
// Home skills use the home directory as their dir_path; project skills use their
|
||||
// owning directory.
|
||||
let mut skill_paths = Vec::new();
|
||||
|
||||
if let Some(home_dir) = dirs::home_dir() {
|
||||
skill_paths.extend(
|
||||
self.home_skill_paths()
|
||||
.into_iter()
|
||||
.map(|path| (home_dir.clone(), path)),
|
||||
);
|
||||
}
|
||||
|
||||
if self.is_cloud_environment {
|
||||
// In cloud environments, all skills are in scope regardless of cwd.
|
||||
for (dir, dir_skill_paths) in &self.directory_skills {
|
||||
if is_home_directory(dir) {
|
||||
continue;
|
||||
}
|
||||
for path in dir_skill_paths {
|
||||
skill_paths.push((dir.clone(), path.clone()));
|
||||
}
|
||||
}
|
||||
} else if let Some(working_directory) = working_directory {
|
||||
let repo_root = repo_metadata::repositories::DetectedRepositories::as_ref(ctx)
|
||||
.get_root_for_path(working_directory);
|
||||
|
||||
for (dir, dir_skill_paths) in &self.directory_skills {
|
||||
if is_home_directory(dir) {
|
||||
continue;
|
||||
}
|
||||
// Only include skills from directories that are ancestors of the working directory
|
||||
// (or the working directory itself)
|
||||
if working_directory.starts_with(dir) {
|
||||
// Also verify this directory is within the detected repo (if any)
|
||||
if repo_root.as_ref().is_none_or(|root| dir.starts_with(root)) {
|
||||
for path in dir_skill_paths {
|
||||
skill_paths.push((dir.clone(), path.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate skills with identical content installed under the same directory across
|
||||
// multiple providers, keeping the skill from the highest-priority provider per
|
||||
// [`SKILL_PROVIDER_DEFINITIONS`].
|
||||
let mut skills = unique_skills(&skill_paths, &self.skills_by_path);
|
||||
|
||||
// Apply icon overrides for well-known skill names (e.g. partner integrations).
|
||||
for skill in &mut skills {
|
||||
if skill.icon_override.is_none() {
|
||||
skill.icon_override =
|
||||
crate::ai::skills::skill_utils::icon_override_for_skill_name(&skill.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Append bundled skills whose activation condition is met.
|
||||
if FeatureFlag::BundledSkills.is_enabled() {
|
||||
skills.extend(
|
||||
self.bundled_skills
|
||||
.iter()
|
||||
.filter(|(_, bundled)| bundled.activation.is_enabled(ctx))
|
||||
.map(|(id, bundled)| {
|
||||
SkillDescriptor::new_bundled(
|
||||
id.clone(),
|
||||
bundled.skill.clone(),
|
||||
bundled.icon,
|
||||
)
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
skills
|
||||
}
|
||||
|
||||
/// Returns the currently-known home skill file paths.
|
||||
pub fn home_skill_paths(&self) -> Vec<PathBuf> {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
return vec![];
|
||||
};
|
||||
self.directory_skills
|
||||
.get(&home_dir)
|
||||
.map(|skills| skills.iter().cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns the currently-known directories which have skills registered.
|
||||
/// This includes both repo roots and subdirectories with skills.
|
||||
pub fn directories_with_skills(&self) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = self.directory_skills.keys().cloned().collect();
|
||||
dirs.sort();
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Returns skill file paths that are under `scope_dir`.
|
||||
///
|
||||
/// This is used for skill resolution when the agent is invoked in a directory
|
||||
/// above a series of repos—we need skills in those repos to be in scope.
|
||||
///
|
||||
/// Example: If `scope_dir` is `/code` and there are skills at:
|
||||
/// - `/code/repo-a/.agents/skills/deploy/SKILL.md`
|
||||
/// - `/code/repo-b/.agents/skills/test/SKILL.md`
|
||||
/// Both will be returned.
|
||||
pub fn skill_paths_in_scope(&self, scope_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut paths = HashSet::new();
|
||||
|
||||
for (dir, skill_paths) in &self.directory_skills {
|
||||
// Include skills from directories that are under scope_dir
|
||||
if dir.starts_with(scope_dir) {
|
||||
paths.extend(skill_paths.iter().cloned());
|
||||
}
|
||||
}
|
||||
|
||||
let mut paths: Vec<PathBuf> = paths.into_iter().collect();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
|
||||
/// Returns true if the skill (or any of its provider-path variants) exists in
|
||||
/// a folder matching one of the given `providers`. This handles the deduplication
|
||||
/// edge case where a skill is present in multiple provider folders (e.g. both
|
||||
/// `.agents/skills/` and `.claude/skills/`) but deduplication picked a provider
|
||||
/// that the caller doesn't support.
|
||||
pub fn skill_exists_for_any_provider(
|
||||
&self,
|
||||
skill: &SkillDescriptor,
|
||||
providers: &[SkillProvider],
|
||||
) -> bool {
|
||||
// Fast path: the deduplicated provider already matches.
|
||||
if providers.contains(&skill.provider) {
|
||||
return true;
|
||||
}
|
||||
// Slow path: check all paths for this skill name.
|
||||
self.skill_paths_by_name(&skill.name)
|
||||
.iter()
|
||||
.filter_map(|path| get_provider_for_path(path))
|
||||
.any(|provider| providers.contains(&provider))
|
||||
}
|
||||
|
||||
/// Returns the best supported provider for a skill given a set of supported providers.
|
||||
///
|
||||
/// When a skill is duplicated across multiple provider folders (e.g. both
|
||||
/// `.agents/skills/` and `.claude/skills/`), the global deduplication picks the
|
||||
/// highest-priority provider per [`SKILL_PROVIDER_DEFINITIONS`]. However, for the
|
||||
/// CLI agent footer `/skills` menu we want the icon to reflect the provider that
|
||||
/// the active CLI agent actually supports.
|
||||
///
|
||||
/// This method checks all paths for the skill name and returns the supported
|
||||
/// provider with the best (lowest) rank. Falls back to the skill's deduped
|
||||
/// provider if no supported provider is found among its paths.
|
||||
pub fn best_supported_provider(
|
||||
&self,
|
||||
skill: &SkillDescriptor,
|
||||
supported_providers: &[SkillProvider],
|
||||
) -> SkillProvider {
|
||||
// Fast path: the deduplicated provider is already supported.
|
||||
if supported_providers.contains(&skill.provider) {
|
||||
return skill.provider;
|
||||
}
|
||||
// Find the supported provider with the best (lowest) rank among all paths.
|
||||
self.skill_paths_by_name(&skill.name)
|
||||
.iter()
|
||||
.filter_map(|path| get_provider_for_path(path))
|
||||
.filter(|provider| supported_providers.contains(provider))
|
||||
.min_by_key(|provider| provider_rank(*provider))
|
||||
.unwrap_or(skill.provider)
|
||||
}
|
||||
|
||||
/// Returns skill file paths that have the given skill name.
|
||||
/// A skill's name comes from the `name` field in its SKILL.md front matter.
|
||||
pub fn skill_paths_by_name(&self, name: &str) -> Vec<PathBuf> {
|
||||
self.skills_by_name
|
||||
.get(name)
|
||||
.map(|paths| {
|
||||
let mut paths: Vec<PathBuf> = paths.iter().cloned().collect();
|
||||
paths.sort();
|
||||
paths
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns a reference to a parsed skill for a specific SKILL.md file path, if it is cached.
|
||||
pub fn skill_by_path(&self, skill_path: &Path) -> Option<&ParsedSkill> {
|
||||
self.skills_by_path.get(skill_path)
|
||||
}
|
||||
|
||||
/// Returns the appropriate `SkillReference` for a skill at the given path.
|
||||
/// For bundled skills, returns `BundledSkillId`; otherwise returns `Path`.
|
||||
pub fn reference_for_skill_path(&self, skill_path: &Path) -> SkillReference {
|
||||
// Check if this path belongs to a bundled skill.
|
||||
for (id, bundled) in &self.bundled_skills {
|
||||
if bundled.skill.path == skill_path {
|
||||
return SkillReference::BundledSkillId(id.clone());
|
||||
}
|
||||
}
|
||||
// Default to path-based reference.
|
||||
SkillReference::Path(skill_path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Get the definition of a skill, if it is cached.
|
||||
pub fn skill_by_reference(&self, reference: &SkillReference) -> Option<&ParsedSkill> {
|
||||
match reference {
|
||||
SkillReference::Path(path) => self.skills_by_path.get(path),
|
||||
SkillReference::BundledSkillId(id) => {
|
||||
self.bundled_skills.get(id).map(|bundled| &bundled.skill)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a bundled skill by ID only if its activation condition is met.
|
||||
pub fn active_bundled_skill(&self, id: &str, ctx: &AppContext) -> Option<&ParsedSkill> {
|
||||
let bundled = self.bundled_skills.get(id)?;
|
||||
bundled.activation.is_enabled(ctx).then_some(&bundled.skill)
|
||||
}
|
||||
|
||||
fn handle_skill_watcher_event(&mut self, event: SkillWatcherEvent) {
|
||||
match event {
|
||||
SkillWatcherEvent::SkillsAdded { skills } => {
|
||||
self.handle_skills_added(skills);
|
||||
}
|
||||
SkillWatcherEvent::SkillsDeleted { paths } => {
|
||||
self.handle_skills_deleted(paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_skills_added(&mut self, skills: Vec<ParsedSkill>) {
|
||||
for skill in skills {
|
||||
if let Ok(parent_dir) = extract_skill_parent_directory(&skill.path) {
|
||||
self.directory_skills
|
||||
.entry(parent_dir)
|
||||
.or_default()
|
||||
.insert(skill.path.clone());
|
||||
|
||||
self.skills_by_name
|
||||
.entry(skill.name.clone())
|
||||
.or_default()
|
||||
.insert(skill.path.clone());
|
||||
self.skills_by_path.insert(skill.path.clone(), skill);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Could not extract parent directory for skill: {:?}",
|
||||
skill.path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_skills_deleted(&mut self, paths: Vec<PathBuf>) {
|
||||
for path in paths {
|
||||
self.handle_path_deleted(&path);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_path_deleted(&mut self, path: &Path) {
|
||||
// Delete all skills that are affected by this deleted path
|
||||
for (dir, skill_paths) in &self.directory_skills.clone() {
|
||||
if dir.starts_with(path) {
|
||||
// Delete this entire entry and remove all skill_paths under this directory from cache
|
||||
for skill_path in skill_paths {
|
||||
let skill = self.skills_by_path.remove(skill_path);
|
||||
if let Some(skill) = skill {
|
||||
self.skills_by_name
|
||||
.entry(skill.name.clone())
|
||||
.or_default()
|
||||
.remove(skill_path);
|
||||
}
|
||||
}
|
||||
self.directory_skills.remove(dir);
|
||||
} else if path.starts_with(dir) {
|
||||
// Remove all skills under this directory that is a child of the deleted path
|
||||
for skill_path in skill_paths {
|
||||
if skill_path.starts_with(path) {
|
||||
let skill = self.skills_by_path.remove(skill_path);
|
||||
if let Some(skill) = skill {
|
||||
self.skills_by_name
|
||||
.entry(skill.name.clone())
|
||||
.or_default()
|
||||
.remove(skill_path);
|
||||
}
|
||||
self.directory_skills
|
||||
.entry(dir.clone())
|
||||
.or_default()
|
||||
.remove(skill_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load skill definitions bundled with Warp.
|
||||
async fn load_bundled_skills() -> HashMap<String, BundledSkill> {
|
||||
let Some(resources_dir) = warp_core::paths::bundled_resources_dir() else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let skills_dir = resources_dir.join("bundled").join("skills");
|
||||
read_bundled_skills(&skills_dir)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|(id, skill)| {
|
||||
let icon = icon_for_bundled_skill(&id);
|
||||
let activation = activation_for_bundled_skill(&id, &resources_dir);
|
||||
let bundled = BundledSkill {
|
||||
skill,
|
||||
activation,
|
||||
icon,
|
||||
};
|
||||
(id, bundled)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Load Figma-specific bundled skills from the `figma/` subdirectory.
|
||||
async fn load_figma_skills() -> HashMap<String, BundledSkill> {
|
||||
let Some(resources_dir) = warp_core::paths::bundled_resources_dir() else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let figma_skills_dir = resources_dir
|
||||
.join("bundled")
|
||||
.join("mcp_skills")
|
||||
.join("figma");
|
||||
read_bundled_skills(&figma_skills_dir)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|(id, skill)| {
|
||||
let bundled = BundledSkill {
|
||||
skill,
|
||||
activation: BundledSkillActivation::RequiresMcp(McpIntegration::Figma),
|
||||
icon: Icon::Figma,
|
||||
};
|
||||
(id, bundled)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Adds a skill to the skill manager for testing purposes.
|
||||
#[cfg(test)]
|
||||
pub fn add_skill_for_testing(&mut self, skill: ParsedSkill) {
|
||||
let path = skill.path.clone();
|
||||
let name = skill.name.clone();
|
||||
self.skills_by_path.insert(path.clone(), skill);
|
||||
self.skills_by_name.entry(name).or_default().insert(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read bundled skill definitions from the specified directory.
|
||||
async fn read_bundled_skills(skills_dir: &Path) -> HashMap<String, ParsedSkill> {
|
||||
use futures::TryStreamExt;
|
||||
|
||||
let mut skills = HashMap::new();
|
||||
let context = build_bundled_skill_context();
|
||||
|
||||
let Ok(mut entries) = async_fs::read_dir(skills_dir).await else {
|
||||
return skills;
|
||||
};
|
||||
|
||||
while let Ok(Some(entry)) = entries.try_next().await {
|
||||
let entry_path = entry.path();
|
||||
if !entry_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let skill_file_path = entry_path.join("SKILL.md");
|
||||
let mut skill = match parse_bundled_skill(&skill_file_path) {
|
||||
Ok(skill) => skill,
|
||||
Err(err) => {
|
||||
report_error!(err.context(format!(
|
||||
"Failed to parse bundled skill at {}",
|
||||
skill_file_path.display()
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// We use the directory name as the skill ID (guaranteed unique within bundled skills).
|
||||
let Some(skill_id) = entry_path.file_name().and_then(|s| s.to_str()) else {
|
||||
safe_warn!(
|
||||
safe: ("Could not resolve bundled skill ID, skipping skill"),
|
||||
full: ("Could not resolve bundled skill ID from {}, skipping skill", skill.path.display())
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
// Apply variable substitution to the skill content.
|
||||
skill.content = handlebars::render_template(&skill.content, &context);
|
||||
skills.insert(skill_id.to_owned(), skill);
|
||||
}
|
||||
|
||||
log::info!("Read {} bundled skills", skills.len());
|
||||
|
||||
skills
|
||||
}
|
||||
|
||||
/// Builds the context map for bundled skill variable substitution.
|
||||
///
|
||||
/// Supported variables:
|
||||
/// - `{{warp_server_url}}` - The server root URL (e.g., `https://api.warp.dev`)
|
||||
/// - `{{warp_cli_binary_name}}` - The CLI binary name (e.g., `warp` or `warp-cli`)
|
||||
/// - `{{warp_url_scheme}}` - The URL scheme (e.g., `warp`, `warpdev`, `warppreview`)
|
||||
/// - `{{settings_schema_path}}` - Path to the bundled JSON settings schema
|
||||
/// - `{{settings_file_path}}` - Path to the user's settings TOML file
|
||||
fn build_bundled_skill_context() -> HashMap<String, String> {
|
||||
let mut context: HashMap<String, String> = [
|
||||
(
|
||||
"warp_server_url".to_owned(),
|
||||
ChannelState::server_root_url().into_owned(),
|
||||
),
|
||||
(
|
||||
"warp_cli_binary_name".to_owned(),
|
||||
ChannelState::channel().cli_command_name().to_owned(),
|
||||
),
|
||||
(
|
||||
"warp_url_scheme".to_owned(),
|
||||
ChannelState::url_scheme().to_owned(),
|
||||
),
|
||||
(
|
||||
"settings_file_path".to_owned(),
|
||||
user_preferences_toml_file_path().display().to_string(),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
if let Some(schema_path) =
|
||||
warp_core::paths::bundled_resources_dir().map(|dir| dir.join("settings_schema.json"))
|
||||
{
|
||||
context.insert(
|
||||
"settings_schema_path".to_owned(),
|
||||
schema_path.display().to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
context
|
||||
}
|
||||
|
||||
/// Returns the icon for a bundled skill, given its directory-based ID.
|
||||
/// Skills with a known brand (e.g. `pr-comments` → GitHub) get a
|
||||
/// branded icon; everything else falls back to the Warp logo.
|
||||
fn icon_for_bundled_skill(skill_id: &str) -> Icon {
|
||||
match skill_id {
|
||||
"pr-comments" => Icon::Github,
|
||||
_ => Icon::WarpLogoLight,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the activation condition for a bundled skill.
|
||||
///
|
||||
/// Most skills are always active. Skills that depend on a bundled resource
|
||||
/// file use `RequiresFile` so they only appear when the resource is present.
|
||||
fn activation_for_bundled_skill(skill_id: &str, resources_dir: &Path) -> BundledSkillActivation {
|
||||
match skill_id {
|
||||
"modify-settings" => {
|
||||
BundledSkillActivation::RequiresFile(resources_dir.join("settings_schema.json"))
|
||||
}
|
||||
_ => BundledSkillActivation::Always,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_home_directory(path: &Path) -> bool {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
return false;
|
||||
};
|
||||
path == home_dir
|
||||
}
|
||||
|
||||
impl Entity for SkillManager {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for SkillManager {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "skill_manager_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,566 @@
|
||||
use super::*;
|
||||
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;
|
||||
use ai::skills::{ParsedSkill, SkillProvider, SkillScope};
|
||||
use repo_metadata::{repositories::DetectedRepositories, DirectoryWatcher, RepoMetadataModel};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
use warp_core::channel::ChannelState;
|
||||
use warpui::App;
|
||||
use watcher::HomeDirectoryWatcher;
|
||||
|
||||
// ============================================================================
|
||||
// Tests for get_skills_for_working_directory subdirectory scoping
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn get_skills_for_working_directory_scopes_subdirectory_skills() {
|
||||
// This test verifies the key scoping behavior:
|
||||
// - Root skills are visible from anywhere in the repo
|
||||
// - Subdirectory skills are only visible when working_directory is within that subdirectory
|
||||
|
||||
// Use real temp directories so DetectedRepositories can canonicalize paths
|
||||
// and correctly report repo_root, which controls ancestor-vs-descendant scoping.
|
||||
// Canonicalize the temp base to avoid macOS /var -> /private/var symlink mismatches.
|
||||
let temp = TempDir::new().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let repo = base.join("repo");
|
||||
let frontend_dir = repo.join("packages/frontend");
|
||||
let backend_dir = repo.join("packages/backend");
|
||||
fs::create_dir_all(&frontend_dir).unwrap();
|
||||
fs::create_dir_all(&backend_dir).unwrap();
|
||||
|
||||
// Create mock skills
|
||||
let root_skill_path = repo.join(".agents/skills/root-skill/SKILL.md");
|
||||
let frontend_skill_path = frontend_dir.join(".agents/skills/frontend-skill/SKILL.md");
|
||||
|
||||
let root_skill = ParsedSkill {
|
||||
name: "root-skill".to_string(),
|
||||
description: "A root skill".to_string(),
|
||||
path: root_skill_path.clone(),
|
||||
content: "# Root skill".to_string(),
|
||||
line_range: None,
|
||||
provider: SkillProvider::Agents,
|
||||
scope: SkillScope::Project,
|
||||
};
|
||||
|
||||
let frontend_skill = ParsedSkill {
|
||||
name: "frontend-skill".to_string(),
|
||||
description: "A frontend skill".to_string(),
|
||||
path: frontend_skill_path.clone(),
|
||||
content: "# Frontend skill".to_string(),
|
||||
line_range: None,
|
||||
provider: SkillProvider::Agents,
|
||||
scope: SkillScope::Project,
|
||||
};
|
||||
|
||||
// Build the internal state manually
|
||||
let mut directory_skills: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
|
||||
directory_skills
|
||||
.entry(repo.clone())
|
||||
.or_default()
|
||||
.insert(root_skill_path.clone());
|
||||
directory_skills
|
||||
.entry(frontend_dir.clone())
|
||||
.or_default()
|
||||
.insert(frontend_skill_path.clone());
|
||||
|
||||
let mut skills_by_path: HashMap<PathBuf, ParsedSkill> = HashMap::new();
|
||||
skills_by_path.insert(root_skill_path.clone(), root_skill);
|
||||
skills_by_path.insert(frontend_skill_path.clone(), frontend_skill);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new);
|
||||
let repo_handle = app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
|
||||
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
|
||||
let skill_manager_handle = app.add_singleton_model(SkillManager::new);
|
||||
|
||||
// Register the repo root so get_root_for_path returns Some.
|
||||
let canonical_repo =
|
||||
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo)
|
||||
.unwrap();
|
||||
repo_handle.update(&mut app, |repos, _ctx| {
|
||||
repos.insert_test_repo_root(canonical_repo);
|
||||
});
|
||||
|
||||
// Inject the test state
|
||||
skill_manager_handle.update(&mut app, |manager, _ctx| {
|
||||
manager.directory_skills = directory_skills;
|
||||
manager.skills_by_path = skills_by_path;
|
||||
});
|
||||
|
||||
// Test 1: From frontend directory, should see both root and frontend skills
|
||||
let skills_from_frontend = skill_manager_handle.read(&app, |manager, ctx| {
|
||||
manager.get_skills_for_working_directory(Some(&frontend_dir), ctx)
|
||||
});
|
||||
let names_from_frontend: Vec<&str> = skills_from_frontend
|
||||
.iter()
|
||||
.map(|s| s.name.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
names_from_frontend.contains(&"root-skill"),
|
||||
"Root skill should be visible from frontend dir"
|
||||
);
|
||||
assert!(
|
||||
names_from_frontend.contains(&"frontend-skill"),
|
||||
"Frontend skill should be visible from frontend dir"
|
||||
);
|
||||
|
||||
// Test 2: From backend directory, should only see root skill (not frontend skill)
|
||||
let skills_from_backend = skill_manager_handle.read(&app, |manager, ctx| {
|
||||
manager.get_skills_for_working_directory(Some(&backend_dir), ctx)
|
||||
});
|
||||
let names_from_backend: Vec<&str> = skills_from_backend
|
||||
.iter()
|
||||
.map(|s| s.name.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
names_from_backend.contains(&"root-skill"),
|
||||
"Root skill should be visible from backend dir"
|
||||
);
|
||||
assert!(
|
||||
!names_from_backend.contains(&"frontend-skill"),
|
||||
"Frontend skill should NOT be visible from backend dir"
|
||||
);
|
||||
|
||||
// Test 3: From repo root, should only see root skill (not frontend skill)
|
||||
let skills_from_root = skill_manager_handle.read(&app, |manager, ctx| {
|
||||
manager.get_skills_for_working_directory(Some(&repo), ctx)
|
||||
});
|
||||
let names_from_root: Vec<&str> = skills_from_root.iter().map(|s| s.name.as_str()).collect();
|
||||
assert!(
|
||||
names_from_root.contains(&"root-skill"),
|
||||
"Root skill should be visible from repo root"
|
||||
);
|
||||
assert!(
|
||||
!names_from_root.contains(&"frontend-skill"),
|
||||
"Frontend skill should NOT be visible from repo root"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_skills_for_working_directory_name_collision_returns_both() {
|
||||
// When the same skill name exists at root and subdirectory, both should be returned.
|
||||
// The caller (agent) is responsible for precedence based on path proximity.
|
||||
|
||||
// Use real temp directories so DetectedRepositories can canonicalize paths.
|
||||
// Canonicalize the temp base to avoid macOS /var -> /private/var symlink mismatches.
|
||||
let temp = TempDir::new().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let repo = base.join("repo");
|
||||
let subdir = repo.join("packages/frontend");
|
||||
fs::create_dir_all(&subdir).unwrap();
|
||||
|
||||
let root_skill_path = repo.join(".agents/skills/deploy/SKILL.md");
|
||||
let subdir_skill_path = subdir.join(".agents/skills/deploy/SKILL.md");
|
||||
|
||||
let root_skill = ParsedSkill {
|
||||
name: "deploy".to_string(),
|
||||
description: "Root deploy".to_string(),
|
||||
path: root_skill_path.clone(),
|
||||
content: "# Root deploy".to_string(),
|
||||
line_range: None,
|
||||
provider: SkillProvider::Agents,
|
||||
scope: SkillScope::Project,
|
||||
};
|
||||
|
||||
let subdir_skill = ParsedSkill {
|
||||
name: "deploy".to_string(),
|
||||
description: "Subdir deploy".to_string(),
|
||||
path: subdir_skill_path.clone(),
|
||||
content: "# Subdir deploy".to_string(),
|
||||
line_range: None,
|
||||
provider: SkillProvider::Agents,
|
||||
scope: SkillScope::Project,
|
||||
};
|
||||
|
||||
let mut directory_skills: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
|
||||
directory_skills
|
||||
.entry(repo.clone())
|
||||
.or_default()
|
||||
.insert(root_skill_path.clone());
|
||||
directory_skills
|
||||
.entry(subdir.clone())
|
||||
.or_default()
|
||||
.insert(subdir_skill_path.clone());
|
||||
|
||||
let mut skills_by_path: HashMap<PathBuf, ParsedSkill> = HashMap::new();
|
||||
skills_by_path.insert(root_skill_path.clone(), root_skill);
|
||||
skills_by_path.insert(subdir_skill_path.clone(), subdir_skill);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new);
|
||||
let repo_handle = app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
|
||||
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
|
||||
let skill_manager_handle = app.add_singleton_model(SkillManager::new);
|
||||
|
||||
// Register the repo root so get_root_for_path returns Some.
|
||||
let canonical_repo =
|
||||
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo)
|
||||
.unwrap();
|
||||
repo_handle.update(&mut app, |repos, _ctx| {
|
||||
repos.insert_test_repo_root(canonical_repo);
|
||||
});
|
||||
|
||||
skill_manager_handle.update(&mut app, |manager, _ctx| {
|
||||
manager.directory_skills = directory_skills;
|
||||
manager.skills_by_path = skills_by_path;
|
||||
});
|
||||
|
||||
// From subdir: should see both "deploy" skills (root + subdir)
|
||||
let skills = skill_manager_handle.read(&app, |manager, ctx| {
|
||||
manager.get_skills_for_working_directory(Some(&subdir), ctx)
|
||||
});
|
||||
let deploy_skills: Vec<_> = skills.iter().filter(|s| s.name == "deploy").collect();
|
||||
assert_eq!(
|
||||
deploy_skills.len(),
|
||||
2,
|
||||
"Both deploy skills should be visible from subdir"
|
||||
);
|
||||
|
||||
// From repo root: should only see root "deploy"
|
||||
let skills = skill_manager_handle.read(&app, |manager, ctx| {
|
||||
manager.get_skills_for_working_directory(Some(&repo), ctx)
|
||||
});
|
||||
let deploy_skills: Vec<_> = skills.iter().filter(|s| s.name == "deploy").collect();
|
||||
assert_eq!(
|
||||
deploy_skills.len(),
|
||||
1,
|
||||
"Only root deploy should be visible from repo root"
|
||||
);
|
||||
assert_eq!(deploy_skills[0].description, "Root deploy");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_environment_skills_always_included() {
|
||||
// In a cloud environment, all skills should be in scope regardless of
|
||||
// the working directory—even when cwd is inside a different repo or
|
||||
// when working_directory is None.
|
||||
|
||||
let temp = TempDir::new().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let repo_a = base.join("repo-a");
|
||||
let repo_b = base.join("repo-b");
|
||||
fs::create_dir_all(&repo_a).unwrap();
|
||||
fs::create_dir_all(&repo_b).unwrap();
|
||||
|
||||
let skill_a_path = repo_a.join(".agents/skills/build/SKILL.md");
|
||||
let skill_b_path = repo_b.join(".agents/skills/deploy/SKILL.md");
|
||||
|
||||
let skill_a = ParsedSkill {
|
||||
name: "build".to_string(),
|
||||
description: "Repo A skill".to_string(),
|
||||
path: skill_a_path.clone(),
|
||||
content: "# Build".to_string(),
|
||||
line_range: None,
|
||||
provider: SkillProvider::Agents,
|
||||
scope: SkillScope::Project,
|
||||
};
|
||||
|
||||
let skill_b = ParsedSkill {
|
||||
name: "deploy".to_string(),
|
||||
description: "Repo B skill".to_string(),
|
||||
path: skill_b_path.clone(),
|
||||
content: "# Deploy".to_string(),
|
||||
line_range: None,
|
||||
provider: SkillProvider::Agents,
|
||||
scope: SkillScope::Project,
|
||||
};
|
||||
|
||||
let mut directory_skills: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
|
||||
directory_skills
|
||||
.entry(repo_a.clone())
|
||||
.or_default()
|
||||
.insert(skill_a_path.clone());
|
||||
directory_skills
|
||||
.entry(repo_b.clone())
|
||||
.or_default()
|
||||
.insert(skill_b_path.clone());
|
||||
|
||||
let mut skills_by_path: HashMap<PathBuf, ParsedSkill> = HashMap::new();
|
||||
skills_by_path.insert(skill_a_path.clone(), skill_a);
|
||||
skills_by_path.insert(skill_b_path.clone(), skill_b);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new);
|
||||
let repo_handle = app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
|
||||
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
|
||||
let skill_manager_handle = app.add_singleton_model(SkillManager::new);
|
||||
|
||||
let canonical_repo_a =
|
||||
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo_a)
|
||||
.unwrap();
|
||||
repo_handle.update(&mut app, |repos, _ctx| {
|
||||
repos.insert_test_repo_root(canonical_repo_a);
|
||||
});
|
||||
|
||||
skill_manager_handle.update(&mut app, |manager, _ctx| {
|
||||
manager.directory_skills = directory_skills;
|
||||
manager.skills_by_path = skills_by_path;
|
||||
manager.is_cloud_environment = true;
|
||||
});
|
||||
|
||||
// From inside repo_a, both repo_a and repo_b skills are visible
|
||||
// because is_cloud_environment skips the ancestor filter.
|
||||
let skills = skill_manager_handle.read(&app, |manager, ctx| {
|
||||
manager.get_skills_for_working_directory(Some(&repo_a), ctx)
|
||||
});
|
||||
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
|
||||
assert!(
|
||||
names.contains(&"build"),
|
||||
"Repo A skill should be visible from repo A"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"deploy"),
|
||||
"Repo B skill should be visible from repo A in cloud environment"
|
||||
);
|
||||
|
||||
// With no working directory, all skills are still included.
|
||||
let skills_none = skill_manager_handle.read(&app, |manager, ctx| {
|
||||
manager.get_skills_for_working_directory(None, ctx)
|
||||
});
|
||||
let names_none: Vec<&str> = skills_none.iter().map(|s| s.name.as_str()).collect();
|
||||
assert!(
|
||||
names_none.contains(&"build"),
|
||||
"Repo A skill should be visible even without a working directory"
|
||||
);
|
||||
assert!(
|
||||
names_none.contains(&"deploy"),
|
||||
"Repo B skill should be visible even without a working directory"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_bundled_skills_with_variable_substitution() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path();
|
||||
|
||||
// Create a test skill with variables
|
||||
let skill_dir = skills_dir.join("test-skill");
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
let skill_file = skill_dir.join("SKILL.md");
|
||||
fs::write(
|
||||
&skill_file,
|
||||
r#"---
|
||||
name: test-skill
|
||||
description: Test skill with variables
|
||||
---
|
||||
|
||||
Run `{{warp_cli_binary_name}}` to connect to {{warp_server_url}}.
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = futures::executor::block_on(read_bundled_skills(skills_dir));
|
||||
|
||||
assert_eq!(skills.len(), 1);
|
||||
let skill = skills.get("test-skill").unwrap();
|
||||
|
||||
let expected_cli = ChannelState::channel().cli_command_name();
|
||||
let expected_url = ChannelState::server_root_url();
|
||||
assert!(skill.content.contains(&format!(
|
||||
"Run `{expected_cli}` to connect to {expected_url}."
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_bundled_skills_preserves_other_content() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path();
|
||||
|
||||
// Create a test skill with both warp and non-warp variables
|
||||
let skill_dir = skills_dir.join("test-skill");
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
let skill_file = skill_dir.join("SKILL.md");
|
||||
fs::write(
|
||||
&skill_file,
|
||||
r#"---
|
||||
name: test-skill
|
||||
description: Test skill with mixed variables
|
||||
---
|
||||
|
||||
Use {{other_var}} and {{warp_cli_binary_name}} together.
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = futures::executor::block_on(read_bundled_skills(skills_dir));
|
||||
|
||||
assert_eq!(skills.len(), 1);
|
||||
let skill = skills.get("test-skill").unwrap();
|
||||
|
||||
let expected_cli = ChannelState::channel().cli_command_name();
|
||||
assert!(skill.content.contains(&format!(
|
||||
"Use {{{{other_var}}}} and {expected_cli} together."
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_bundled_skills_no_variables() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path();
|
||||
|
||||
// Create a test skill with no variables
|
||||
let skill_dir = skills_dir.join("test-skill");
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
let skill_file = skill_dir.join("SKILL.md");
|
||||
fs::write(
|
||||
&skill_file,
|
||||
r#"---
|
||||
name: test-skill
|
||||
description: Test skill without variables
|
||||
---
|
||||
|
||||
Plain content with no variables.
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = futures::executor::block_on(read_bundled_skills(skills_dir));
|
||||
|
||||
assert_eq!(skills.len(), 1);
|
||||
let skill = skills.get("test-skill").unwrap();
|
||||
assert!(skill.content.contains("Plain content with no variables."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_bundled_skill_context() {
|
||||
let context = build_bundled_skill_context();
|
||||
|
||||
// At least 4 entries: server_url, cli_binary_name, url_scheme, settings_file_path.
|
||||
// settings_schema_path is only present when bundled_resources_dir() returns Some.
|
||||
assert!(context.len() >= 4);
|
||||
assert!(context.contains_key("warp_server_url"));
|
||||
assert!(context.contains_key("warp_cli_binary_name"));
|
||||
assert!(context.contains_key("warp_url_scheme"));
|
||||
assert!(context.contains_key("settings_file_path"));
|
||||
|
||||
assert_eq!(
|
||||
context.get("warp_server_url").unwrap(),
|
||||
&ChannelState::server_root_url().to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
context.get("warp_cli_binary_name").unwrap(),
|
||||
ChannelState::channel().cli_command_name()
|
||||
);
|
||||
assert_eq!(
|
||||
context.get("warp_url_scheme").unwrap(),
|
||||
ChannelState::url_scheme()
|
||||
);
|
||||
assert_eq!(
|
||||
context.get("settings_file_path").unwrap(),
|
||||
&crate::settings::user_preferences_toml_file_path()
|
||||
.display()
|
||||
.to_string()
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests for best_supported_provider
|
||||
// ============================================================================
|
||||
|
||||
/// Helper: creates a ParsedSkill under a given provider directory.
|
||||
fn make_skill(name: &str, provider_dir: &str) -> ParsedSkill {
|
||||
let path = PathBuf::from(format!("/repo/{provider_dir}/skills/{name}/SKILL.md"));
|
||||
ParsedSkill {
|
||||
name: name.to_string(),
|
||||
description: format!("{name} skill"),
|
||||
path,
|
||||
content: format!("# {name}"),
|
||||
line_range: None,
|
||||
provider: get_provider_for_path(&PathBuf::from(format!(
|
||||
"/repo/{provider_dir}/skills/{name}/SKILL.md"
|
||||
)))
|
||||
.unwrap_or(SkillProvider::Warp),
|
||||
scope: SkillScope::Project,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_supported_provider_fast_path_returns_deduped_provider() {
|
||||
// When the deduped provider is already in the supported set, return it immediately.
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
|
||||
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
|
||||
let handle = app.add_singleton_model(SkillManager::new);
|
||||
|
||||
let claude_skill = make_skill("deploy", ".claude");
|
||||
handle.update(&mut app, |manager, _| {
|
||||
manager.add_skill_for_testing(claude_skill.clone());
|
||||
});
|
||||
|
||||
let descriptor = SkillDescriptor::from(claude_skill);
|
||||
let result = handle.read(&app, |manager, _| {
|
||||
manager.best_supported_provider(&descriptor, &[SkillProvider::Claude])
|
||||
});
|
||||
assert_eq!(result, SkillProvider::Claude);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_supported_provider_remaps_to_supported_provider() {
|
||||
// Skill exists under both .agents and .claude. Dedup picked Agents (higher priority).
|
||||
// When supported set is [Claude], should re-map to Claude.
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
|
||||
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
|
||||
let handle = app.add_singleton_model(SkillManager::new);
|
||||
|
||||
let agents_skill = make_skill("deploy", ".agents");
|
||||
let claude_skill = make_skill("deploy", ".claude");
|
||||
handle.update(&mut app, |manager, _| {
|
||||
manager.add_skill_for_testing(agents_skill.clone());
|
||||
manager.add_skill_for_testing(claude_skill.clone());
|
||||
});
|
||||
|
||||
// Descriptor has provider = Agents (the dedup winner).
|
||||
let descriptor = SkillDescriptor::from(agents_skill);
|
||||
assert_eq!(descriptor.provider, SkillProvider::Agents);
|
||||
|
||||
let result = handle.read(&app, |manager, _| {
|
||||
manager.best_supported_provider(&descriptor, &[SkillProvider::Claude])
|
||||
});
|
||||
assert_eq!(result, SkillProvider::Claude);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_supported_provider_falls_back_when_no_match() {
|
||||
// Skill only exists under .agents, but the supported set is [Claude].
|
||||
// Should fall back to the original deduped provider (Agents).
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
|
||||
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
|
||||
let handle = app.add_singleton_model(SkillManager::new);
|
||||
|
||||
let agents_skill = make_skill("deploy", ".agents");
|
||||
handle.update(&mut app, |manager, _| {
|
||||
manager.add_skill_for_testing(agents_skill.clone());
|
||||
});
|
||||
|
||||
let descriptor = SkillDescriptor::from(agents_skill);
|
||||
let result = handle.read(&app, |manager, _| {
|
||||
manager.best_supported_provider(&descriptor, &[SkillProvider::Claude])
|
||||
});
|
||||
// No .claude path exists, so falls back to the deduped provider.
|
||||
assert_eq!(result, SkillProvider::Agents);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Utility functions for working with skills.
|
||||
|
||||
use super::{SkillDescriptor, SkillManager};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::view_util::render_provider_icon_button;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use ai::skills::{
|
||||
home_skills_path, provider_rank, ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS,
|
||||
};
|
||||
use lazy_static::lazy_static;
|
||||
use siphasher::sip::SipHasher;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::prelude::MouseStateHandle;
|
||||
use warpui::EventContext;
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
use crate::warp_managed_paths_watcher::warp_managed_skill_dirs;
|
||||
|
||||
lazy_static! {
|
||||
static ref CONTENT_HASHER: SipHasher = SipHasher::new_with_keys(0, 0);
|
||||
}
|
||||
|
||||
/// Tries to insert or update a skill descriptor in the deduplication map.
|
||||
/// If a skill with the same (directory, content) key already exists, keeps the one
|
||||
/// from the higher-priority provider based on [`SKILL_PROVIDER_DEFINITIONS`].
|
||||
fn try_insert_skill(
|
||||
dedup_map: &mut HashMap<u64, SkillDescriptor>,
|
||||
descriptor: SkillDescriptor,
|
||||
dir_path: &Path,
|
||||
content: &str,
|
||||
) {
|
||||
let mut hasher = *CONTENT_HASHER;
|
||||
// Hash the directory path and content to create a unique key for deduplication.
|
||||
dir_path.hash(&mut hasher);
|
||||
content.hash(&mut hasher);
|
||||
let key = hasher.finish();
|
||||
match dedup_map.entry(key) {
|
||||
Entry::Vacant(e) => {
|
||||
e.insert(descriptor);
|
||||
}
|
||||
Entry::Occupied(mut e) => {
|
||||
// Prefer the skill from the higher-priority provider.
|
||||
if provider_rank(descriptor.provider) < provider_rank(e.get().provider) {
|
||||
e.insert(descriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deduplicates skills with identical content installed under the same directory across
|
||||
/// multiple providers, keeping the single best representative per
|
||||
/// [`SKILL_PROVIDER_DEFINITIONS`] (index 0 = highest priority).
|
||||
///
|
||||
/// Two skills are considered duplicates only when they share the same owning directory
|
||||
/// **and** identical content — which is the common case when a tool like `npx skills`
|
||||
/// symlinks the same skill under `~/.agents/skills/`, `~/.warp/skills/`, `~/.claude/skills/`, etc.
|
||||
///
|
||||
/// Each element of `skill_paths` is a `(dir_path, skill_file_path)` tuple where
|
||||
/// `dir_path` is the directory that owns the skill.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub(crate) fn unique_skills(
|
||||
skill_paths: &[(PathBuf, PathBuf)],
|
||||
skills_by_path: &HashMap<PathBuf, ParsedSkill>,
|
||||
) -> Vec<SkillDescriptor> {
|
||||
// hash(dir_path + content) → best descriptor seen so far
|
||||
let mut dedup_map: HashMap<u64, SkillDescriptor> = HashMap::new();
|
||||
|
||||
for (dir_path, path) in skill_paths {
|
||||
if let Some(skill) = skills_by_path.get(path) {
|
||||
try_insert_skill(
|
||||
&mut dedup_map,
|
||||
SkillDescriptor::from(skill.clone()),
|
||||
dir_path,
|
||||
&skill.content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dedup_map.into_values().collect()
|
||||
}
|
||||
|
||||
/// Returns the list of skills if they have changed since the last time we sent them to the server.
|
||||
/// Skills are always included except when the current list matches the last list sent.
|
||||
pub fn list_skills_if_changed(
|
||||
working_directory: Option<&Path>,
|
||||
conversation_id: Option<AIConversationId>,
|
||||
app: &AppContext,
|
||||
) -> Option<Vec<SkillDescriptor>> {
|
||||
let current_skills =
|
||||
SkillManager::as_ref(app).get_skills_for_working_directory(working_directory, app);
|
||||
|
||||
let previous_skills: Option<Vec<SkillDescriptor>> =
|
||||
conversation_id.and_then(|conversation_id| {
|
||||
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
||||
history_model
|
||||
.conversation(&conversation_id)
|
||||
.and_then(|conversation| conversation.latest_skills())
|
||||
});
|
||||
|
||||
// If there are no previous skills, we consider the skills changed and push the current skills to the context
|
||||
let skills_changed = previous_skills
|
||||
.map(|previous_skills| {
|
||||
let previous_skills_set: HashSet<SkillDescriptor> =
|
||||
HashSet::from_iter(previous_skills.iter().cloned());
|
||||
let current_skills_set: HashSet<SkillDescriptor> =
|
||||
HashSet::from_iter(current_skills.iter().cloned());
|
||||
|
||||
previous_skills_set != current_skills_set
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
if skills_changed {
|
||||
Some(current_skills)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders an 'open skill' button for blocklist AI actions and the code diff view.
|
||||
pub fn render_skill_button<F>(
|
||||
button_label: &str,
|
||||
button_handle: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
skill_provider: SkillProvider,
|
||||
icon_override: Option<Icon>,
|
||||
on_click: F,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
F: FnMut(&mut EventContext) + 'static,
|
||||
{
|
||||
let theme = appearance.theme();
|
||||
let logo_fill = internal_colors::fg_overlay_6(theme);
|
||||
|
||||
let icon = icon_override.unwrap_or_else(|| skill_provider.icon());
|
||||
|
||||
let color = if icon_override.is_some() {
|
||||
logo_fill
|
||||
} else {
|
||||
skill_provider.icon_fill(logo_fill)
|
||||
};
|
||||
|
||||
render_provider_icon_button(
|
||||
button_label,
|
||||
button_handle,
|
||||
appearance,
|
||||
icon,
|
||||
color,
|
||||
on_click,
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a branded icon override for well-known skill names.
|
||||
pub fn icon_override_for_skill_name(name: &str) -> Option<Icon> {
|
||||
match name {
|
||||
"stripe-projects-cli" => Some(Icon::StripeLogo),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn skill_path_from_file_path(file_path: &Path) -> Option<PathBuf> {
|
||||
for definition in SKILL_PROVIDER_DEFINITIONS.iter() {
|
||||
let home_skill_dirs = if definition.provider == SkillProvider::Warp {
|
||||
warp_managed_skill_dirs()
|
||||
} else {
|
||||
home_skills_path(definition.provider).into_iter().collect()
|
||||
};
|
||||
for home_skills_path in home_skill_dirs {
|
||||
if let Ok(relative_path) = file_path.strip_prefix(&home_skills_path) {
|
||||
let skill_name = relative_path.components().next()?;
|
||||
return Some(home_skills_path.join(skill_name).join("SKILL.md"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let path_components: Vec<_> = file_path.components().collect();
|
||||
|
||||
for def in SKILL_PROVIDER_DEFINITIONS.iter() {
|
||||
let skill_components: Vec<_> = def.skills_path.components().collect();
|
||||
|
||||
for (idx, window) in path_components.windows(skill_components.len()).enumerate() {
|
||||
if window == skill_components.as_slice() {
|
||||
let skill_dir = PathBuf::from_iter(
|
||||
file_path
|
||||
.components()
|
||||
.take(idx + skill_components.len() + 1),
|
||||
);
|
||||
return Some(skill_dir.join("SKILL.md"));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "skill_utils_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,185 @@
|
||||
use super::*;
|
||||
use ai::skills::{ParsedSkill, SkillProvider, SkillScope};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn test_skill_path_from_file_path_skill_md() {
|
||||
let skill = PathBuf::from("/home/user/.claude/skills/my-skill/SKILL.md");
|
||||
let result = skill_path_from_file_path(&skill);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(PathBuf::from("/home/user/.claude/skills/my-skill/SKILL.md"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_path_from_file_path_warp_home_skill() {
|
||||
let Some(warp_home_skills_dir) = warp_core::paths::warp_home_skills_dir() else {
|
||||
eprintln!("Skipping test: Warp home skills directory not available");
|
||||
return;
|
||||
};
|
||||
let warp_home_skill = warp_home_skills_dir
|
||||
.join("my-skill")
|
||||
.join("assets")
|
||||
.join("image.png");
|
||||
let result = skill_path_from_file_path(&warp_home_skill);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(warp_home_skills_dir.join("my-skill").join("SKILL.md"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_path_from_file_path_nested_file() {
|
||||
let skill_nested = PathBuf::from("/home/user/.agents/skills/my-skill/assets/image.png");
|
||||
let result = skill_path_from_file_path(&skill_nested);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(PathBuf::from("/home/user/.agents/skills/my-skill/SKILL.md"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_path_from_file_path_non_skill() {
|
||||
let non_skill = PathBuf::from("/home/user/Documents/file.txt");
|
||||
let result = skill_path_from_file_path(&non_skill);
|
||||
assert_eq!(result, None);
|
||||
|
||||
let similar_path = PathBuf::from("/home/user/.claude/other/file.txt");
|
||||
let result = skill_path_from_file_path(&similar_path);
|
||||
assert_eq!(result, None);
|
||||
|
||||
let empty_path = PathBuf::from("");
|
||||
let result = skill_path_from_file_path(&empty_path);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unique_skills_dedupes_identical_skills_same_dir() {
|
||||
let shared_skill_dir = PathBuf::from("/home/user");
|
||||
let skill_path1 = shared_skill_dir.join(".agents/skills/my-skill/SKILL.md");
|
||||
let skill_path2 = shared_skill_dir.join(".claude/skills/my-skill/SKILL.md");
|
||||
|
||||
let content = "---\nname: test-skill\ndescription: A test skill\n---\nContent here";
|
||||
let skill = ParsedSkill {
|
||||
path: skill_path1.clone(),
|
||||
name: "test-skill".to_string(),
|
||||
description: "A test skill".to_string(),
|
||||
content: content.to_string(),
|
||||
line_range: Some(8..18),
|
||||
provider: SkillProvider::Agents,
|
||||
scope: SkillScope::Home,
|
||||
};
|
||||
|
||||
let skill2 = ParsedSkill {
|
||||
path: skill_path2.clone(),
|
||||
name: "test-skill".to_string(),
|
||||
description: "A test skill".to_string(),
|
||||
content: content.to_string(),
|
||||
line_range: Some(8..18),
|
||||
provider: SkillProvider::Claude,
|
||||
scope: SkillScope::Home,
|
||||
};
|
||||
|
||||
let mut skills_by_path = HashMap::new();
|
||||
skills_by_path.insert(skill_path1.clone(), skill);
|
||||
skills_by_path.insert(skill_path2.clone(), skill2);
|
||||
|
||||
let skill_paths = vec![
|
||||
(shared_skill_dir.clone(), skill_path1),
|
||||
(shared_skill_dir, skill_path2),
|
||||
];
|
||||
|
||||
let result = unique_skills(&skill_paths, &skills_by_path);
|
||||
assert_eq!(result.len(), 1);
|
||||
// Agents has higher priority (index 0) than Claude, so it should be preferred
|
||||
assert_eq!(result[0].provider, SkillProvider::Agents);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unique_skills_does_not_dedupe_different_dirs() {
|
||||
let home_dir = PathBuf::from("/home/user");
|
||||
let project_dir = PathBuf::from("/home/user/projects/repo");
|
||||
let home_path = home_dir.join(".agents/skills/my-skill/SKILL.md");
|
||||
let project_path = project_dir.join(".agents/skills/my-skill/SKILL.md");
|
||||
|
||||
let content = "---\nname: test-skill\ndescription: A test skill\n---\nContent here";
|
||||
let home_skill = ParsedSkill {
|
||||
path: home_path.clone(),
|
||||
name: "test-skill".to_string(),
|
||||
description: "A test skill".to_string(),
|
||||
content: content.to_string(),
|
||||
line_range: Some(8..18),
|
||||
provider: SkillProvider::Agents,
|
||||
scope: SkillScope::Home,
|
||||
};
|
||||
|
||||
let project_skill = ParsedSkill {
|
||||
path: project_path.clone(),
|
||||
name: "test-skill".to_string(),
|
||||
description: "A test skill".to_string(),
|
||||
content: content.to_string(),
|
||||
line_range: Some(8..18),
|
||||
provider: SkillProvider::Agents,
|
||||
scope: SkillScope::Project,
|
||||
};
|
||||
|
||||
let mut skills_by_path = HashMap::new();
|
||||
skills_by_path.insert(home_path.clone(), home_skill);
|
||||
skills_by_path.insert(project_path.clone(), project_skill);
|
||||
|
||||
let skill_paths = vec![(home_dir, home_path), (project_dir, project_path)];
|
||||
|
||||
let result = unique_skills(&skill_paths, &skills_by_path);
|
||||
assert_eq!(
|
||||
result.len(),
|
||||
2,
|
||||
"Skills with same content but different directories should not be deduped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unique_skills_does_not_dedupe_different_content() {
|
||||
let shared_skill_dir = PathBuf::from("/home/user");
|
||||
let skill_path1 = shared_skill_dir.join(".agents/skills/my-skill/SKILL.md");
|
||||
let skill_path2 = shared_skill_dir.join(".claude/skills/my-skill/SKILL.md");
|
||||
|
||||
let content1 = "---\nname: test-skill\ndescription: A test skill\n---\nContent here";
|
||||
let content2 = "---\nname: test-skill\ndescription: A test skill\n---\nDifferent content";
|
||||
|
||||
let skill1 = ParsedSkill {
|
||||
path: skill_path1.clone(),
|
||||
name: "test-skill".to_string(),
|
||||
description: "A test skill".to_string(),
|
||||
content: content1.to_string(),
|
||||
line_range: Some(8..18),
|
||||
provider: SkillProvider::Agents,
|
||||
scope: SkillScope::Home,
|
||||
};
|
||||
|
||||
let skill2 = ParsedSkill {
|
||||
path: skill_path2.clone(),
|
||||
name: "test-skill".to_string(),
|
||||
description: "A test skill".to_string(),
|
||||
content: content2.to_string(),
|
||||
line_range: Some(8..18),
|
||||
provider: SkillProvider::Claude,
|
||||
scope: SkillScope::Home,
|
||||
};
|
||||
|
||||
let mut skills_by_path = HashMap::new();
|
||||
skills_by_path.insert(skill_path1.clone(), skill1);
|
||||
skills_by_path.insert(skill_path2.clone(), skill2);
|
||||
|
||||
let skill_paths = vec![
|
||||
(shared_skill_dir.clone(), skill_path1),
|
||||
(shared_skill_dir, skill_path2),
|
||||
];
|
||||
|
||||
let result = unique_skills(&skill_paths, &skills_by_path);
|
||||
assert_eq!(
|
||||
result.len(),
|
||||
2,
|
||||
"Skills with different content should not be deduped even if same directory and name"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use ai::skills::{SkillProvider, SkillReference, SkillScope};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use strum_macros::{EnumDiscriminants, EnumIter};
|
||||
use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
|
||||
|
||||
use crate::features::FeatureFlag;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SkillOpenOrigin {
|
||||
// 'Open skill' button on ReadSkill tool call result
|
||||
ReadSkill,
|
||||
// 'Open skill' button on ReadFiles tool call result
|
||||
ReadFiles,
|
||||
// 'Open skill' button on CodeDiffView
|
||||
EditFiles,
|
||||
// /open-skill command
|
||||
OpenSkillCommand,
|
||||
}
|
||||
|
||||
/// Telemetry events for skills
|
||||
#[derive(Serialize, Debug, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(EnumIter))]
|
||||
pub enum SkillTelemetryEvent {
|
||||
/// Emitted when a skill is invoked via the ReadSkill tool call.
|
||||
Read {
|
||||
/// Specifies the unique reference to the skill, which is either a path or a bundled skill ID
|
||||
reference: SkillReference,
|
||||
/// Specifies the parsed skill name from SKILL.md front matter, if the reference resolved
|
||||
name: Option<String>,
|
||||
/// Specifies the scope of the skill (home or project)
|
||||
scope: Option<SkillScope>,
|
||||
/// Specifies the provider of the skill (Warp, Claude, Codex, etc.)
|
||||
provider: Option<SkillProvider>,
|
||||
/// Whether the ReadSkill lookup failed (reference could not be resolved)
|
||||
error: bool,
|
||||
},
|
||||
/// Emitted when a SKILL.md file is opened from an 'open skill' button or /edit-skill command.
|
||||
Opened {
|
||||
/// Specifies the unique reference to the skill, which is either a path or a bundled skill ID
|
||||
reference: SkillReference,
|
||||
/// Specifies the parsed skill name from SKILL.md front matter, if the reference resolved
|
||||
name: Option<String>,
|
||||
/// Specifies the origin of the skill open (button or command)
|
||||
origin: SkillOpenOrigin,
|
||||
},
|
||||
}
|
||||
|
||||
impl TelemetryEvent for SkillTelemetryEvent {
|
||||
fn name(&self) -> &'static str {
|
||||
SkillTelemetryEventDiscriminants::from(self).name()
|
||||
}
|
||||
|
||||
fn payload(&self) -> Option<serde_json::Value> {
|
||||
match self {
|
||||
SkillTelemetryEvent::Read {
|
||||
reference,
|
||||
name,
|
||||
scope,
|
||||
provider,
|
||||
error,
|
||||
} => Some(json!({
|
||||
"reference": reference,
|
||||
"name": name,
|
||||
"scope": scope,
|
||||
"provider": provider,
|
||||
"error": error,
|
||||
})),
|
||||
SkillTelemetryEvent::Opened {
|
||||
reference,
|
||||
name,
|
||||
origin,
|
||||
} => Some(json!({
|
||||
"reference": reference,
|
||||
"name": name,
|
||||
"origin": origin,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
SkillTelemetryEventDiscriminants::from(self).description()
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
SkillTelemetryEventDiscriminants::from(self).enablement_state()
|
||||
}
|
||||
|
||||
fn contains_ugc(&self) -> bool {
|
||||
// context: Skills are created by users, and the events we emit contain the `name` field,
|
||||
// which is defined by the user.
|
||||
true
|
||||
}
|
||||
|
||||
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
|
||||
warp_core::telemetry::enum_events::<Self>()
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryEventDesc for SkillTelemetryEventDiscriminants {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Read => "Skill.Read",
|
||||
Self::Opened => "Skill.Opened",
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Read => "A skill was read via the ReadSkill tool call",
|
||||
Self::Opened => "A skill was opened from an 'open skill' button or /edit-skill command",
|
||||
}
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
EnablementState::Flag(FeatureFlag::ListSkills)
|
||||
}
|
||||
}
|
||||
|
||||
warp_core::register_telemetry_event!(SkillTelemetryEvent);
|
||||
Reference in New Issue
Block a user