#[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 galaxy_core::{ channel::ChannelState, features::FeatureFlag, report_error, safe_warn, ui::icons::Icon, }; use galaxyui::{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>, skills_by_path: HashMap, /// 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>, /// Skills bundled into Warp, each with activation condition and icon. bundled_skills: HashMap, /// 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, // Can't remove this or it'll get cleaned up after new() } impl SkillManager { pub fn new(ctx: &mut ModelContext) -> 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 { // 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 { 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 { let mut dirs: Vec = 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 { 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 = 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 { self.skills_by_name .get(name) .map(|paths| { let mut paths: Vec = 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) { 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) { 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 { let Some(resources_dir) = galaxy_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 { let Some(resources_dir) = galaxy_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 { 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`) /// - `{{galaxy_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 { let mut context: HashMap = [ ( "warp_server_url".to_owned(), ChannelState::server_root_url().into_owned(), ), ( "galaxy_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) = galaxy_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;