Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+60
View File
@@ -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 {}
+7
View File
@@ -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;
})
}
}
+149
View File
@@ -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());
});
});
});
}
+50
View File
@@ -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,
}
}
}
+34
View File
@@ -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};
}
}
+571
View File
@@ -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(())
}
+603
View File
@@ -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;
+566
View File
@@ -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);
});
}
+202
View File
@@ -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;
+185
View File
@@ -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"
);
}
+121
View File
@@ -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);