Files
galaxy/app/src/ai/skills/skill_manager.rs
T

604 lines
22 KiB
Rust

#[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<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) = 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<String, BundledSkill> {
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<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`)
/// - `{{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<String, String> {
let mut context: HashMap<String, String> = [
(
"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;