first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+530
View File
@@ -0,0 +1,530 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use ai::skills::{parse_bundled_skill, ParsedSkill, SkillPathOrigin, SkillReference};
use futures::TryStreamExt;
use galaxy_core::channel::ChannelState;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::icons::Icon;
use galaxy_core::{report_error, safe_warn};
use warp_util::host_id::HostId;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use warp_util::remote_path::RemotePath;
use warpui::{AppContext, SingletonEntity};
use super::SkillDescriptor;
use crate::ai::mcp::{McpIntegration, TemplatableMCPServerManager};
use crate::keyboard::keybinding_file_path;
use crate::settings::user_preferences_toml_file_path;
/// Activation condition for a bundled skill.
#[derive(Debug, Clone)]
pub enum BundledSkillActivation {
/// Always active.
Always,
/// Active only when a specific Warp feature is enabled.
RequiresFeature(FeatureFlag),
/// 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::RequiresFeature(feature) => feature.is_enabled(),
Self::RequiresMcp(integration) => {
TemplatableMCPServerManager::as_ref(ctx).is_mcp_server_running(*integration)
}
Self::RequiresFile(path) => path.exists(),
}
}
}
/// Catalogs of bundled skills for the local host and connected remote hosts.
#[derive(Debug, Default)]
pub struct BundledSkills {
local: BundledSkill,
remote_by_host: HashMap<HostId, BundledSkill>,
}
impl BundledSkills {
pub fn set_local(&mut self, bundled_skill: BundledSkill) {
self.local = bundled_skill;
}
pub fn active_descriptors(
&self,
path_origin: &SkillPathOrigin,
ctx: &AppContext,
) -> Vec<SkillDescriptor> {
match path_origin {
SkillPathOrigin::Local | SkillPathOrigin::RestoredDisplayOnly => {
self.local.active_descriptors(ctx)
}
SkillPathOrigin::Remote { host_id } => self
.remote(host_id)
.map(|bundled_skill| bundled_skill.active_path_referenced_descriptors(ctx))
.unwrap_or_default(),
SkillPathOrigin::Unavailable => Vec::new(),
}
}
pub fn reference_for_path(&self, path: &LocalOrRemotePath) -> Option<SkillReference> {
self.local.reference_for_path(path)
}
pub fn local_skill(&self, id: &str) -> Option<&ParsedSkill> {
self.local.skill(id)
}
pub fn active_skill(
&self,
id: &str,
path_origin: &SkillPathOrigin,
ctx: &AppContext,
) -> Option<&ParsedSkill> {
self.for_path_origin(path_origin)?.active_skill(id, ctx)
}
/// Installs the catalog for a connected remote host, replacing any
/// previous catalog from an earlier connection.
pub fn insert_remote(&mut self, host_id: HostId, bundled_skill: BundledSkill) {
self.remote_by_host.insert(host_id, bundled_skill);
}
/// Removes all catalog state for a disconnected remote host.
pub fn remove_remote(&mut self, host_id: &HostId) {
self.remote_by_host.remove(host_id);
}
/// Returns the catalog for a connected remote host.
pub fn remote(&self, host_id: &HostId) -> Option<&BundledSkill> {
self.remote_by_host.get(host_id)
}
/// Returns the remote catalog skill matching `path`, looked up in the
/// catalog of the host that owns the path. Remote bundled skills are
/// addressed by path (their paths are real files on the remote host),
/// unlike local bundled skills which are addressed by
/// [`SkillReference::BundledSkillId`].
pub fn remote_skill_by_path(&self, path: &RemotePath) -> Option<&ParsedSkill> {
self.remote_by_host
.get(&path.host_id)?
.skill_by_path(&LocalOrRemotePath::Remote(path.clone()))
}
/// Like [`Self::remote_skill_by_path`], but only returns the skill when
/// its activation condition is met.
pub fn remote_active_skill_by_path(
&self,
path: &RemotePath,
ctx: &AppContext,
) -> Option<&ParsedSkill> {
self.remote_by_host
.get(&path.host_id)?
.active_skill_by_path(&LocalOrRemotePath::Remote(path.clone()), ctx)
}
/// Returns the bundled catalog selected by the execution path origin.
fn for_path_origin(&self, path_origin: &SkillPathOrigin) -> Option<&BundledSkill> {
match path_origin {
SkillPathOrigin::Local | SkillPathOrigin::RestoredDisplayOnly => Some(&self.local),
SkillPathOrigin::Remote { host_id } => self.remote(host_id),
SkillPathOrigin::Unavailable => None,
}
}
#[cfg(test)]
pub fn insert_local_for_testing(
&mut self,
id: impl Into<String>,
skill: ParsedSkill,
activation: BundledSkillActivation,
) {
self.local.insert_for_testing(id, skill, activation);
}
#[cfg(test)]
pub fn insert_remote_for_testing(
&mut self,
host_id: HostId,
id: impl Into<String>,
skill: ParsedSkill,
activation: BundledSkillActivation,
) {
self.remote_by_host
.entry(host_id)
.or_default()
.insert_for_testing(id, skill, activation);
}
}
/// One bundled skill definition with its activation condition and icon.
#[derive(Debug, Clone)]
struct BundledSkillDefinition {
skill: ParsedSkill,
activation: BundledSkillActivation,
icon: Icon,
}
/// Skills bundled with Warp for a single host.
#[derive(Debug, Default)]
pub struct BundledSkill {
definitions: HashMap<String, BundledSkillDefinition>,
}
impl BundledSkill {
/// Detect all skill definitions bundled with Warp for the local host.
pub async fn detect() -> Self {
let Some(resources_dir) = galaxy_core::paths::bundled_resources_dir() else {
return Self::default();
};
Self::detect_in_resources_dir(resources_dir).await
}
/// Detect all skill definitions under the given resources root on the
/// local filesystem, rendering skill content against this host.
///
/// Called directly by the remote-server daemon, whose resources live at
/// the global install location rather than inside an app bundle (which
/// is what [`galaxy_core::paths::bundled_resources_dir`] resolves).
pub(crate) async fn detect_in_resources_dir(resources_dir: PathBuf) -> Self {
let (mut definitions, figma_definitions) = futures::join!(
load_bundled_skill_definitions(&resources_dir),
load_figma_skill_definitions(&resources_dir)
);
definitions.extend(figma_definitions);
Self { definitions }
}
/// Returns descriptors for bundled skills whose activation conditions are met.
pub fn active_descriptors(&self, ctx: &AppContext) -> Vec<SkillDescriptor> {
self.definitions
.iter()
.filter(|(_, definition)| definition.activation.is_enabled(ctx))
.map(|(id, definition)| {
SkillDescriptor::new_bundled(id.clone(), definition.skill.clone(), definition.icon)
})
.collect()
}
/// Returns descriptors for bundled skills whose activation conditions are
/// met, referenced by their `SKILL.md` paths instead of
/// [`SkillReference::BundledSkillId`].
///
/// Used for remote-host catalogs: a `BundledSkillId` reference resolves
/// against the local catalog, so descriptors listed from a remote catalog
/// must carry the skill's real remote path — which resolves back to this
/// catalog through the path lookups — or invoking a listed skill would
/// serve the local client's content.
pub fn active_path_referenced_descriptors(&self, ctx: &AppContext) -> Vec<SkillDescriptor> {
self.definitions
.values()
.filter(|definition| definition.activation.is_enabled(ctx))
.map(|definition| {
let mut descriptor = SkillDescriptor::from(definition.skill.clone());
descriptor.icon_override = Some(definition.icon);
descriptor
})
.collect()
}
/// Returns a bundled skill reference when the path belongs to a bundled skill.
pub fn reference_for_path(&self, path: &LocalOrRemotePath) -> Option<SkillReference> {
self.definitions
.iter()
.find(|(_, definition)| definition.skill.path == *path)
.map(|(id, _)| SkillReference::BundledSkillId(id.clone()))
}
/// Returns a bundled skill definition by ID.
pub fn skill(&self, id: &str) -> Option<&ParsedSkill> {
self.definitions.get(id).map(|definition| &definition.skill)
}
/// Returns a bundled skill by ID only if its activation condition is met.
pub fn active_skill(&self, id: &str, ctx: &AppContext) -> Option<&ParsedSkill> {
let definition = self.definitions.get(id)?;
definition
.activation
.is_enabled(ctx)
.then_some(&definition.skill)
}
/// Returns a bundled skill by its `SKILL.md` path.
pub fn skill_by_path(&self, path: &LocalOrRemotePath) -> Option<&ParsedSkill> {
self.definitions
.values()
.map(|definition| &definition.skill)
.find(|skill| skill.path == *path)
}
/// Returns a bundled skill by its `SKILL.md` path only if its activation
/// condition is met.
pub fn active_skill_by_path(
&self,
path: &LocalOrRemotePath,
ctx: &AppContext,
) -> Option<&ParsedSkill> {
self.definitions
.values()
.find(|definition| definition.skill.path == *path)
.filter(|definition| definition.activation.is_enabled(ctx))
.map(|definition| &definition.skill)
}
/// Builds a catalog from pre-parsed definitions. Used for catalogs
/// received from a remote host's daemon, which parses and renders the
/// skills against its own filesystem.
pub(crate) fn from_definitions(
definitions: impl IntoIterator<Item = (String, ParsedSkill, BundledSkillActivation)>,
) -> Self {
let definitions = definitions
.into_iter()
.map(|(id, skill, activation)| {
// MCP-gated skills carry their integration's brand icon, like
// the local figma catalog loaded from `mcp_skills/figma`.
let icon = match &activation {
BundledSkillActivation::RequiresMcp(McpIntegration::Figma) => Icon::Figma,
BundledSkillActivation::Always
| BundledSkillActivation::RequiresFeature(_)
| BundledSkillActivation::RequiresFile(_) => icon_for_bundled_skill(&id),
};
(
id,
BundledSkillDefinition {
skill,
activation,
icon,
},
)
})
.collect();
Self { definitions }
}
/// Iterates the catalog's definitions as `(id, skill, activation)`.
/// Used by the daemon to serialize its catalog for the
/// aggregate remote Agent Mode context snapshot.
pub(crate) fn iter_definitions(
&self,
) -> impl Iterator<Item = (&str, &ParsedSkill, &BundledSkillActivation)> {
self.definitions
.iter()
.map(|(id, definition)| (id.as_str(), &definition.skill, &definition.activation))
}
#[cfg(test)]
pub fn insert_for_testing(
&mut self,
id: impl Into<String>,
skill: ParsedSkill,
activation: BundledSkillActivation,
) {
let id = id.into();
self.definitions.insert(
id.clone(),
BundledSkillDefinition {
skill,
activation,
icon: icon_for_bundled_skill(&id),
},
);
}
}
/// Load skill definitions bundled with Warp.
async fn load_bundled_skill_definitions(
resources_dir: &Path,
) -> HashMap<String, BundledSkillDefinition> {
let skills_dir = resources_dir.join("bundled").join("skills");
read_bundled_skills(&skills_dir, resources_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 = BundledSkillDefinition {
skill,
activation,
icon,
};
(id, bundled)
})
.collect()
}
/// Load Figma-specific bundled skills from the `figma/` subdirectory.
async fn load_figma_skill_definitions(
resources_dir: &Path,
) -> HashMap<String, BundledSkillDefinition> {
let figma_skills_dir = resources_dir
.join("bundled")
.join("mcp_skills")
.join("figma");
read_bundled_skills(&figma_skills_dir, resources_dir)
.await
.into_iter()
.map(|(id, skill)| {
let bundled = BundledSkillDefinition {
skill,
activation: BundledSkillActivation::RequiresMcp(McpIntegration::Figma),
icon: Icon::Figma,
};
(id, bundled)
})
.collect()
}
/// Read bundled skill definitions from the specified directory, rendering
/// handlebars variables against this host's filesystem (`resources_dir` is
/// the resources root the skills belong to).
///
/// Only ever runs against the calling process's own filesystem: the local
/// app reads its bundled resources, and the remote daemon reads its global
/// install location before pushing the rendered content over the wire.
/// Clients never call this for files on another host, so local `Path`
/// semantics (this OS's encoding) are correct here.
pub(crate) async fn read_bundled_skills(
skills_dir: &Path,
resources_dir: &Path,
) -> HashMap<String, ParsedSkill> {
let mut skills = HashMap::new();
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_path())
);
continue;
};
let context = build_bundled_skill_context(resources_dir, &entry_path);
// 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`)
/// - `{{warpctrl_binary_name}}` - The channel-specific Warp Control command name
/// - `{{warpctrl_wrapper_path}}` - Path to the bundled Warp Control wrapper
/// - `{{warp_url_scheme}}` - The URL scheme (e.g., `warp`, `warpdev`, `warppreview`)
/// - `{{settings_schema_path}}` - Path to the bundled JSON settings schema
/// - `{{skill_dir}}` - Path to the bundled skill's directory
/// - `{{settings_file_path}}` - Path to the user's settings TOML file
/// - `{{keybindings_file_path}}` - Path to the user's keybindings YAML file
pub(crate) fn build_bundled_skill_context(
resources_dir: &Path,
skill_dir: &Path,
) -> 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(),
),
(
"warpctrl_binary_name".to_owned(),
ChannelState::channel().warpctrl_command_name().to_owned(),
),
(
"warpctrl_wrapper_path".to_owned(),
resources_dir
.join("bin")
.join(ChannelState::channel().warpctrl_command_name())
.display()
.to_string(),
),
(
"warp_url_scheme".to_owned(),
ChannelState::url_scheme().to_owned(),
),
(
"settings_file_path".to_owned(),
user_preferences_toml_file_path().display().to_string(),
),
(
"keybindings_file_path".to_owned(),
keybinding_file_path().display().to_string(),
),
(
"settings_schema_path".to_owned(),
resources_dir
.join("settings_schema.json")
.display()
.to_string(),
),
("skill_dir".to_owned(), skill_dir.display().to_string()),
]
.into_iter()
.collect()
}
/// 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.
pub(crate) 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. Other skills appear only when their required
/// feature, integration, or bundled resource is available.
pub(crate) 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"))
}
"warpctrl" => BundledSkillActivation::RequiresFeature(FeatureFlag::WarpControlCli),
_ => BundledSkillActivation::Always,
}
}
#[cfg(test)]
#[path = "bundled_tests.rs"]
mod tests;
+76
View File
@@ -0,0 +1,76 @@
use ai::skills::{ParsedSkill, SkillProvider, SkillScope};
use warp_util::host_id::HostId;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use super::*;
fn bundled_skill(content: &str) -> BundledSkill {
let mut bundled_skill = BundledSkill::default();
bundled_skill.insert_for_testing(
"test-skill",
ParsedSkill {
name: "test-skill".to_string(),
description: "Test skill".to_string(),
path: LocalOrRemotePath::Local("/bundled/skills/test-skill/SKILL.md".into()),
content: content.to_string(),
line_range: None,
provider: SkillProvider::Warp,
scope: SkillScope::Bundled,
},
BundledSkillActivation::Always,
);
bundled_skill
}
fn remote_content<'a>(bundled_skills: &'a BundledSkills, host_id: &HostId) -> Option<&'a str> {
bundled_skills
.remote(host_id)?
.skill("test-skill")
.map(|skill| skill.content.as_str())
}
#[test]
fn local_and_remote_catalogs_are_isolated() {
let first_host_id = HostId::new("first-host".to_string());
let second_host_id = HostId::new("second-host".to_string());
let mut bundled_skills = BundledSkills::default();
bundled_skills.set_local(bundled_skill("local"));
bundled_skills.insert_remote(first_host_id.clone(), bundled_skill("first"));
bundled_skills.insert_remote(second_host_id.clone(), bundled_skill("second"));
assert_eq!(
bundled_skills
.local_skill("test-skill")
.map(|skill| skill.content.as_str()),
Some("local")
);
assert_eq!(
remote_content(&bundled_skills, &first_host_id),
Some("first")
);
assert_eq!(
remote_content(&bundled_skills, &second_host_id),
Some("second")
);
// A reconnect refresh replaces the host's catalog wholesale.
bundled_skills.insert_remote(first_host_id.clone(), bundled_skill("first-refreshed"));
assert_eq!(
remote_content(&bundled_skills, &first_host_id),
Some("first-refreshed")
);
// Disconnecting one host leaves the local and sibling-host catalogs intact.
bundled_skills.remove_remote(&first_host_id);
assert_eq!(
bundled_skills
.local_skill("test-skill")
.map(|skill| skill.content.as_str()),
Some("local")
);
assert_eq!(remote_content(&bundled_skills, &first_host_id), None);
assert_eq!(
remote_content(&bundled_skills, &second_host_id),
Some("second")
);
}
+45 -11
View File
@@ -1,9 +1,10 @@
use std::path::Path;
use ai::skills::{ParsedSkill, SkillProvider, SkillReference};
use ai::skills::{ParsedSkill, SkillPathOrigin, SkillProvider, SkillReference};
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
use crate::ai::skills::SkillDescriptor;
use crate::ai::skills::{
ActiveSkillLookupError, SkillDescriptor, SkillManagerEvent, SkillPathQuery,
};
pub struct SkillManager {}
@@ -14,25 +15,58 @@ impl SkillManager {
pub fn get_skills_for_working_directory(
&self,
_working_directory: Option<&Path>,
_working_directory: Option<&LocalOrRemotePath>,
_ctx: &AppContext,
) -> Vec<SkillDescriptor> {
vec![]
}
pub fn skill_by_path(&self, _skill_path: &Path) -> Option<&ParsedSkill> {
None
pub fn get_skills_for_working_directory_with_origin(
&self,
_working_directory: Option<&LocalOrRemotePath>,
_path_origin: &SkillPathOrigin,
_ctx: &AppContext,
) -> Vec<SkillDescriptor> {
vec![]
}
pub fn reference_for_skill_path(&self, skill_path: &Path) -> SkillReference {
SkillReference::Path(skill_path.to_path_buf())
pub fn skill_by_path<P: SkillPathQuery + ?Sized>(
&self,
_skill_path: &P,
) -> Option<&ParsedSkill> {
None
}
pub fn reference_for_skill_path<P: SkillPathQuery + ?Sized>(
&self,
skill_path: &P,
) -> SkillReference {
SkillReference::Path(skill_path.to_skill_location())
}
pub fn skill_by_reference(&self, _reference: &SkillReference) -> Option<&ParsedSkill> {
None
}
pub fn active_bundled_skill(&self, _id: &str, _ctx: &AppContext) -> Option<&ParsedSkill> {
pub fn active_skill_by_reference(
&self,
_reference: &SkillReference,
_ctx: &AppContext,
) -> Option<&ParsedSkill> {
None
}
pub fn active_skill_by_reference_with_origin(
&self,
reference: &SkillReference,
path_origin: &SkillPathOrigin,
_ctx: &AppContext,
) -> Result<&ParsedSkill, ActiveSkillLookupError> {
Err(ActiveSkillLookupError::for_reference(
reference,
path_origin,
))
}
pub fn active_local_bundled_skill(&self, _id: &str, _ctx: &AppContext) -> Option<&ParsedSkill> {
None
}
@@ -54,7 +88,7 @@ impl SkillManager {
}
impl Entity for SkillManager {
type Event = ();
type Event = SkillManagerEvent;
}
impl SingletonEntity for SkillManager {}
+1 -1
View File
@@ -4,4 +4,4 @@ mod skill_watcher;
pub use skill_watcher::{SkillWatcher, SkillWatcherEvent};
mod utils;
pub use utils::extract_skill_parent_directory;
pub use utils::{extract_skill_parent_directory, read_skills_from_directories};
+454 -239
View File
@@ -1,57 +1,62 @@
use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::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::galaxy_managed_paths_watcher::{
filter_repository_update_by_prefix, galaxy_managed_skill_dirs, GalaxyManagedPathsWatcher,
GalaxyManagedPathsWatcherEvent,
};
use crate::server::datetime_ext::DateTimeExt;
use ai::skills::{
home_skills_path, parse_skill, ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS,
get_provider_for_path, home_skills_path, parse_skill, parse_skill_content_at_location,
ParsedSkill, SkillProvider, SkillScope, SKILL_PROVIDER_DEFINITIONS,
};
use async_channel::Sender;
use chrono::{DateTime, Duration, Utc};
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
use repo_metadata::{
repositories::{DetectedRepositories, RepoDetectionSource},
repository::{Repository, SubscriberId},
DirectoryWatcher, RepoMetadataModel, RepositoryUpdate,
use futures::future::BoxFuture;
use repo_metadata::repositories::DetectedRepositories;
use repo_metadata::repository::{Repository, SubscriberId};
use repo_metadata::{DirectoryWatcher, RepoMetadataModel, RepositoryIdentifier, RepositoryUpdate};
use warp_util::local_or_remote_path::LocalOrRemotePath;
use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
use watcher::{BulkFilesystemWatcherEvent, HomeDirectoryWatcher, HomeDirectoryWatcherEvent};
use super::subscribers::{
HomeSkillSubscriber, ProjectSkillSubscriber, SkillRepositoryMessage, SymlinkSkillSubscriber,
};
use super::utils::{
find_local_project_skill_files_on_filesystem, find_project_skill_files_in_tree,
is_home_provider_path, is_home_skill_directory, is_skill_file, read_skills_from_directories,
read_skills_from_files,
};
use crate::ai::remote_context_files::{
read_remote_text_file_contents, REMOTE_CONTEXT_MAX_BATCH_BYTES, REMOTE_CONTEXT_MAX_FILE_BYTES,
};
use crate::warp_managed_paths_watcher::{
filter_repository_update_by_prefix, warp_managed_skill_dirs, WarpManagedPathsWatcher,
WarpManagedPathsWatcherEvent,
};
#[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>,
SkillsDeleted { paths: Vec<LocalOrRemotePath> },
}
type ProjectSkillContentsFuture =
BoxFuture<'static, anyhow::Result<Vec<(LocalOrRemotePath, String)>>>;
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>,
/// Last known project skill files by repository. Relevant repo metadata changes trigger a
/// full refresh; precise unrelated incremental deltas are ignored.
project_skill_files_by_repo: HashMap<RepositoryIdentifier, HashSet<LocalOrRemotePath>>,
/// Latest full project-skill refresh generation by repository. Repo metadata refreshes
/// hydrate project skills asynchronously, so results from superseded tree snapshots
/// must not re-add deleted skills or overwrite newer parsed content.
project_skill_refresh_generations: HashMap<RepositoryIdentifier, u64>,
/// Allocates refresh generations that cannot be reused if a repository is removed
/// and subsequently re-added while an old task is still in flight.
next_project_skill_refresh_generation: u64,
/// Failed local repos still need the project file watcher path because
/// repo metadata indexing can fail for oversized repos. This replaces the
/// previous `watched_repos` set so we only subscribe when fallback is active
/// and can also clean up the subscriber on repo removal.
failed_local_project_watchers: HashMap<PathBuf, (ModelHandle<Repository>, SubscriberId)>,
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.
@@ -69,18 +74,22 @@ pub struct SkillWatcher {
}
impl SkillWatcher {
/// Synchronously reads skills from the given repo paths.
/// Synchronously reads skills from the given local 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> {
pub fn read_local_skills_for_repos(
repo_paths: &[PathBuf],
ctx: &AppContext,
) -> Vec<ParsedSkill> {
let repo_metadata = RepoMetadataModel::as_ref(ctx);
let skill_dirs: Vec<PathBuf> = repo_paths
let skill_files: Vec<PathBuf> = repo_paths
.iter()
.flat_map(|repo_path| find_skill_directories_in_tree(repo_path, repo_metadata, ctx))
.filter_map(|repo_path| RepositoryIdentifier::try_local(repo_path))
.flat_map(|repo_id| find_project_skill_files_in_tree(&repo_id, repo_metadata, ctx))
.filter_map(|path| path.to_local_path().map(Path::to_path_buf))
.collect();
read_skills_from_directories(skill_dirs)
read_skills_from_files(skill_files)
}
pub fn new(ctx: &mut ModelContext<Self>, watcher_event_tx: Sender<SkillWatcherEvent>) -> Self {
@@ -116,20 +125,28 @@ impl SkillWatcher {
);
if home_dir.is_some() {
ctx.subscribe_to_model(
&HomeDirectoryWatcher::handle(ctx),
|me, event, ctx| match event {
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);
},
);
ctx.subscribe_to_model(&GalaxyManagedPathsWatcher::handle(ctx), |me, event, ctx| {
me.handle_warp_managed_paths_event(event, ctx);
});
}
// Subscribe to home directory skills via DirectoryWatcher.
// TODO: Migrate home/global skill watching onto RepoMetadataModel as well.
// Project skills have moved there first so local and remote project
// behavior share one path and avoid a separate local FileWatcher. Home
// provider directories and symlink target watches still use
// DirectoryWatcher/HomeDirectoryWatcher for now, but should eventually
// follow the same model for consistency.
//
// We watch each skills "parent directory" under the home directory (e.g., `~/.agents`,
// `~/.claude`) rather than the entire home directory, to reduce watch overhead.
@@ -159,63 +176,41 @@ impl SkillWatcher {
}
}
// 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| {
// RepositoryMetadataEvent::RepositoryUpdated fires after the file tree is
// built, so we can query it for skill files. Project skill updates use
// RepoMetadataModel for both local and remote repos when available, while
// local repos fall back to a direct project watcher only if metadata
// indexing fails.
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::RepositoryUpdated { id } => {
me.refresh_project_skills_for_repo(id, ctx);
}
RepoMetadataEvent::StandingQueryResultsUpdated { id, delta } => {
if delta.project_skills_changed() {
me.refresh_project_skills_for_repo(id, ctx);
}
}
RepoMetadataEvent::FileTreeEntryUpdated { .. } => {
me.handle_queued_project_directory_creations(ctx);
RepoMetadataEvent::RepositoryRemoved { id } => {
me.remove_project_skills_for_repo(id);
me.stop_failed_local_project_watcher(id, ctx);
}
RepoMetadataEvent::RepositoryUpdated { .. }
| RepoMetadataEvent::RepositoryRemoved { .. }
| RepoMetadataEvent::FileTreeUpdated { .. }
| RepoMetadataEvent::UpdatingRepositoryFailed { .. }
RepoMetadataEvent::UpdatingRepositoryFailed { id } => {
me.fallback_to_local_project_watcher(id, ctx);
}
RepoMetadataEvent::FileTreeUpdated { .. }
| RepoMetadataEvent::FileTreeEntryUpdated { .. }
| 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(),
project_skill_files_by_repo: HashMap::new(),
project_skill_refresh_generations: HashMap::new(),
next_project_skill_refresh_generation: 0,
failed_local_project_watchers: HashMap::new(),
watcher_event_tx,
home_provider_watchers,
symlink_canonical_to_originals: HashMap::new(),
@@ -223,47 +218,249 @@ impl SkillWatcher {
}
}
/// 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) {
fn refresh_project_skills_for_repo(
&mut self,
repo_id: &RepositoryIdentifier,
ctx: &mut ModelContext<Self>,
) {
let refresh_generation = self.advance_project_skill_refresh_generation(repo_id);
let current_skill_files: HashSet<LocalOrRemotePath> = {
let repo_metadata = RepoMetadataModel::as_ref(ctx);
find_project_skill_files_in_tree(repo_id, repo_metadata, ctx)
.into_iter()
.collect()
};
let previous_skill_files = self
.project_skill_files_by_repo
.get(repo_id)
.cloned()
.unwrap_or_default();
let deleted_paths = previous_skill_files
.difference(&current_skill_files)
.cloned()
.collect::<Vec<_>>();
if !deleted_paths.is_empty() {
let deleted_local_paths = deleted_paths
.iter()
.filter_map(|path| path.to_local_path().map(Path::to_path_buf))
.collect::<Vec<_>>();
self.cleanup_symlink_watches(&deleted_local_paths);
let _ = self
.watcher_event_tx
.try_send(SkillWatcherEvent::SkillsDeleted {
paths: deleted_paths,
});
}
// Project skill counts are expected to be small, so initial discovery and
// skill-relevant repo metadata updates trigger a full refresh rather than
// attempting to maintain project-skill state incrementally.
self.spawn_read_project_skills_from_files(
repo_id.clone(),
refresh_generation,
current_skill_files.iter().cloned().collect(),
ctx,
);
self.project_skill_files_by_repo
.insert(repo_id.clone(), current_skill_files);
}
fn advance_project_skill_refresh_generation(&mut self, repo_id: &RepositoryIdentifier) -> u64 {
self.next_project_skill_refresh_generation += 1;
self.project_skill_refresh_generations
.insert(repo_id.clone(), self.next_project_skill_refresh_generation);
self.next_project_skill_refresh_generation
}
fn fallback_to_local_project_watcher(
&mut self,
repo_id: &RepositoryIdentifier,
ctx: &mut ModelContext<Self>,
) {
let RepositoryIdentifier::Local(repo_path) = repo_id else {
return;
};
let Some(local_path) = repo_path.to_local_path() else {
return;
};
self.scan_local_project_skills_from_filesystem(&local_path, ctx);
self.watch_failed_local_project_repo(local_path, ctx);
}
/// Register a failed local project root to watch for skill file changes.
fn watch_failed_local_project_repo(
&mut self,
repo_path: PathBuf,
ctx: &mut ModelContext<Self>,
) {
if self.failed_local_project_watchers.contains_key(&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 Some(repo_handle) =
DetectedRepositories::as_ref(ctx).get_local_watched_repo_for_path(&repo_path, ctx)
else {
log::warn!(
"Could not start local project skill fallback watcher for {}; repo is not watched",
repo_path.display()
);
return;
};
let subscriber = Box::new(ProjectSkillSubscriber {
message_tx: self.repository_message_tx.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));
let subscriber_id = start.subscriber_id;
self.failed_local_project_watchers
.insert(repo_path.clone(), (repo_handle.clone(), subscriber_id));
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);
ctx.spawn(start.registration_future, move |me, res, ctx| {
if let Err(err) = res {
log::warn!(
"Failed to start local project skill fallback watcher for {}: {err}",
repo_path.display()
);
if let Some((repo_handle, subscriber_id)) =
me.failed_local_project_watchers.remove(&repo_path)
{
repo_handle.update(ctx, |repo, ctx| {
repo.stop_watching(start.subscriber_id, ctx)
repo.stop_watching(subscriber_id, ctx);
});
}
});
}
});
}
fn scan_local_project_skills_from_filesystem(
&mut self,
repo_path: &Path,
ctx: &mut ModelContext<Self>,
) {
let repo_path = repo_path.to_path_buf();
ctx.spawn(
async move { find_local_project_skill_files_on_filesystem(&repo_path) },
move |me, skill_paths, ctx| {
me.spawn_read_fallback_project_skills_from_files(skill_paths, ctx);
},
);
}
fn spawn_read_project_skills_from_files(
&mut self,
repo_id: RepositoryIdentifier,
refresh_generation: u64,
skill_paths: Vec<LocalOrRemotePath>,
ctx: &mut ModelContext<Self>,
) {
if skill_paths.is_empty() {
return;
}
let Some(read_skill_contents) = read_project_skill_contents(skill_paths, ctx) else {
return;
};
ctx.spawn(
async move { read_and_parse_project_skills(read_skill_contents).await },
move |me, skills, ctx| match skills {
Ok(skills) => {
me.emit_project_skills_if_current(&repo_id, refresh_generation, skills, ctx);
}
Err(err) => log::warn!("Failed to read project skills: {err}"),
},
);
}
fn emit_project_skills_if_current(
&mut self,
repo_id: &RepositoryIdentifier,
refresh_generation: u64,
skills: Vec<ParsedSkill>,
ctx: &mut ModelContext<Self>,
) {
if self.project_skill_refresh_generations.get(repo_id) != Some(&refresh_generation) {
return;
}
self.emit_project_skills(skills, ctx);
}
fn emit_project_skills(&mut self, skills: Vec<ParsedSkill>, ctx: &mut ModelContext<Self>) {
if !skills.is_empty() {
self.register_symlink_watches(&skills, ctx);
let _ = self
.watcher_event_tx
.try_send(SkillWatcherEvent::SkillsAdded { skills });
}
}
/// 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() {
fn spawn_read_fallback_project_skills_from_files(
&mut self,
skill_paths: Vec<LocalOrRemotePath>,
ctx: &mut ModelContext<Self>,
) {
if skill_paths.is_empty() {
return;
}
Self::spawn_read_skills_from_directories(skill_dirs, ctx);
let Some(read_skill_contents) = read_project_skill_contents(skill_paths, ctx) else {
return;
};
ctx.spawn(
async move { read_and_parse_project_skills(read_skill_contents).await },
|me, skills, ctx| match skills {
Ok(skills) => me.emit_project_skills(skills, ctx),
Err(err) => log::warn!("Failed to read fallback project skills: {err}"),
},
);
}
fn stop_failed_local_project_watcher(
&mut self,
repo_id: &RepositoryIdentifier,
ctx: &mut ModelContext<Self>,
) {
let RepositoryIdentifier::Local(repo_path) = repo_id else {
return;
};
let Some(local_path) = repo_path.to_local_path() else {
return;
};
let Some((repo_handle, subscriber_id)) =
self.failed_local_project_watchers.remove(&local_path)
else {
return;
};
repo_handle.update(ctx, |repo, ctx| {
repo.stop_watching(subscriber_id, ctx);
});
}
fn remove_project_skills_for_repo(&mut self, repo_id: &RepositoryIdentifier) {
// Invalidate an in-flight full refresh before deleting its currently cached skills.
// New refreshes use globally increasing generations, so the entry can be dropped
// without colliding if the same repository is later re-added.
self.project_skill_refresh_generations.remove(repo_id);
let Some(skill_files) = self.project_skill_files_by_repo.remove(repo_id) else {
return;
};
let deleted_paths = skill_files.into_iter().collect::<Vec<_>>();
if !deleted_paths.is_empty() {
let deleted_local_paths = deleted_paths
.iter()
.filter_map(|path| path.to_local_path().map(Path::to_path_buf))
.collect::<Vec<_>>();
self.cleanup_symlink_watches(&deleted_local_paths);
let _ = self
.watcher_event_tx
.try_send(SkillWatcherEvent::SkillsDeleted {
paths: deleted_paths,
});
}
}
fn spawn_read_skills_from_directories(
@@ -300,7 +497,10 @@ impl SkillWatcher {
.watcher_event_tx
.try_send(SkillWatcherEvent::SkillsAdded { skills });
}
SkillRepositoryMessage::RepositoryUpdate { update } => {
SkillRepositoryMessage::ProjectRepositoryUpdate { update } => {
self.handle_failed_local_project_update(&update, ctx);
}
SkillRepositoryMessage::HomeRepositoryUpdate { update } => {
self.handle_repository_update(&update, ctx);
}
SkillRepositoryMessage::SymlinkTargetUpdate { update } => {
@@ -309,12 +509,73 @@ impl SkillWatcher {
}
}
fn handle_failed_local_project_update(
&mut self,
update: &RepositoryUpdate,
ctx: &mut ModelContext<Self>,
) {
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());
self.handle_failed_local_project_added_or_modified_path(&to_target.path, ctx);
}
// Process added or modified files
for target_file in update.added_or_modified() {
self.handle_failed_local_project_added_or_modified_path(&target_file.path, ctx);
}
// 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
.into_iter()
.map(LocalOrRemotePath::Local)
.collect(),
});
}
}
fn handle_failed_local_project_added_or_modified_path(
&mut self,
path: &Path,
ctx: &mut ModelContext<Self>,
) {
let skill_file_path = if is_skill_file(path) {
Some(path.to_path_buf())
} else if path.is_symlink() && path.is_dir() && path.join("SKILL.md").exists() {
Some(path.join("SKILL.md"))
} else {
None
};
if let Some(skill_file_path) = skill_file_path {
self.spawn_read_fallback_project_skills_from_files(
vec![LocalOrRemotePath::Local(skill_file_path)],
ctx,
);
} else if path.is_dir() {
// The original local watcher deferred added directories until RepoMetadataModel
// incorporated them. This handler runs only after metadata indexing has failed, so
// scan added directories directly from disk instead of waiting for an update that
// may never arrive.
self.scan_local_project_skills_from_filesystem(path, 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();
@@ -340,14 +601,7 @@ impl SkillWatcher {
});
}
} 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());
}
home_path_additions.insert(to_target.path.clone());
}
}
@@ -390,14 +644,7 @@ impl SkillWatcher {
},
);
} 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());
}
home_path_additions.insert(target_file.path.clone());
}
}
@@ -440,90 +687,12 @@ impl SkillWatcher {
let _ = self
.watcher_event_tx
.try_send(SkillWatcherEvent::SkillsDeleted {
paths: deleted_paths,
paths: deleted_paths
.into_iter()
.map(LocalOrRemotePath::Local)
.collect(),
});
}
// 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.
@@ -556,18 +725,20 @@ impl SkillWatcher {
/// 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 Some(original_path) = skill.path.to_local_path() else {
continue;
};
let Ok(canonical_path) = dunce::canonicalize(original_path) else {
continue;
};
if canonical_path == *original_path {
if canonical_path == original_path {
continue; // Not a symlink
}
self.symlink_canonical_to_originals
.entry(canonical_path.clone())
.or_default()
.insert(original_path.clone());
.insert(original_path.to_path_buf());
let Some(canonical_dir) = canonical_path.parent() else {
continue;
@@ -656,7 +827,10 @@ impl SkillWatcher {
let _ = self
.watcher_event_tx
.try_send(SkillWatcherEvent::SkillsDeleted {
paths: deleted_original_paths,
paths: deleted_original_paths
.into_iter()
.map(LocalOrRemotePath::Local)
.collect(),
});
}
@@ -702,14 +876,6 @@ impl SkillWatcher {
}
}
// 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(
@@ -789,7 +955,10 @@ impl SkillWatcher {
let _ = self
.watcher_event_tx
.try_send(SkillWatcherEvent::SkillsDeleted {
paths: deleted_paths,
paths: deleted_paths
.into_iter()
.map(LocalOrRemotePath::Local)
.collect(),
});
}
@@ -871,6 +1040,52 @@ impl SkillWatcher {
}
}
fn read_project_skill_contents(
skill_paths: Vec<LocalOrRemotePath>,
ctx: &AppContext,
) -> Option<ProjectSkillContentsFuture> {
match skill_paths.first()? {
LocalOrRemotePath::Local(_) => Some(Box::pin(async move {
Ok(read_local_project_skill_contents(skill_paths))
})),
LocalOrRemotePath::Remote(_) => Some(read_remote_text_file_contents(
skill_paths,
Some(REMOTE_CONTEXT_MAX_FILE_BYTES),
Some(REMOTE_CONTEXT_MAX_BATCH_BYTES),
ctx,
)),
}
}
async fn read_and_parse_project_skills(
read_skill_contents: ProjectSkillContentsFuture,
) -> anyhow::Result<Vec<ParsedSkill>> {
Ok(parse_project_skill_contents(read_skill_contents.await?))
}
fn read_local_project_skill_contents(
skill_paths: Vec<LocalOrRemotePath>,
) -> Vec<(LocalOrRemotePath, String)> {
skill_paths
.into_iter()
.filter_map(|path| {
let content = fs::read_to_string(path.to_local_path()?).ok()?;
Some((path, content))
})
.collect()
}
fn parse_project_skill_contents(
skill_contents: Vec<(LocalOrRemotePath, String)>,
) -> Vec<ParsedSkill> {
skill_contents
.into_iter()
.filter_map(|(path, content)| {
let provider = get_provider_for_path(&path).unwrap_or(SkillProvider::Agents);
parse_skill_content_at_location(path, &content, provider, SkillScope::Project).ok()
})
.collect()
}
impl Entity for SkillWatcher {
type Event = SkillWatcherEvent;
}
@@ -1,22 +1,37 @@
use std::{
collections::{HashMap, HashSet},
fs,
};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
use crate::ai::skills::skill_manager::SkillWatcherEvent;
use ai::skills::{ParsedSkill, SkillProvider, SkillScope};
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::App;
use repo_metadata::entry::{DirectoryEntry, Entry, FileMetadata};
use repo_metadata::file_tree_store::FileTreeState;
use repo_metadata::repositories::DetectedRepositories;
use repo_metadata::{
repositories::DetectedRepositories, DirectoryWatcher, RepoMetadataModel, RepositoryUpdate,
TargetFile,
DirectoryWatcher, RepoMetadataModel, RepositoryIdentifier, RepositoryUpdate,
StandingQueryContent, StandingQueryResults, StandingQueryResultsDelta, TargetFile,
};
use tempfile::TempDir;
use galaxy_util::host_id::HostId;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxy_util::remote_path::RemotePath;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::App;
use super::SkillWatcher;
use super::super::subscribers::SkillRepositoryMessage;
use super::{parse_project_skill_contents, SkillWatcher};
use crate::ai::skills::skill_manager::SkillWatcherEvent;
/// Helper function for creating a single skill file
fn create_skill_file(dir: &TempDir, name: &str, description: &str, content: &str) -> ParsedSkill {
create_skill_file_in_directory(dir.path(), name, description, content)
}
fn create_skill_file_in_directory(
parent_dir: &std::path::Path,
name: &str,
description: &str,
content: &str,
) -> ParsedSkill {
let skill_content = format!(
r#"---
name: {}
@@ -26,7 +41,7 @@ description: {}
"#,
name, description, content
);
let skills_path = dir.path().join(".agents").join("skills");
let skills_path = parent_dir.join(".agents").join("skills");
let skill_dir_path = skills_path.join(name);
let skill_file_path = skill_dir_path.join("SKILL.md");
@@ -35,7 +50,7 @@ description: {}
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,
path: LocalOrRemotePath::Local(skill_file_path),
name: name.to_string(),
description: description.to_string(),
content: skill_content.clone(),
@@ -45,6 +60,66 @@ description: {}
}
}
fn skill_local_path(skill: &ParsedSkill) -> PathBuf {
skill.path.to_local_path().unwrap().to_path_buf()
}
fn remote_skill_path(host_id: &HostId, name: &str) -> LocalOrRemotePath {
LocalOrRemotePath::Remote(RemotePath::new(
host_id.clone(),
StandardizedPath::try_new(format!("/repo/.agents/skills/{name}/SKILL.md").as_str())
.unwrap(),
))
}
fn remote_skill_content(name: &str, description: &str, body: &str) -> String {
format!(
r#"---
name: {name}
description: {description}
---
{body}
"#
)
}
#[test]
fn parse_project_skill_contents_preserves_remote_paths() {
let host = HostId::new("test-host".to_string());
let first_path = remote_skill_path(&host, "first");
let second_path = remote_skill_path(&host, "second");
let first_content = remote_skill_content("first", "First skill", "First body");
let second_content = remote_skill_content("second", "Second skill", "Second body");
let skills = parse_project_skill_contents(vec![
(first_path.clone(), first_content.clone()),
(second_path.clone(), second_content.clone()),
]);
assert_eq!(skills.len(), 2);
assert_eq!(skills[0].path, first_path);
assert_eq!(skills[0].name, "first");
assert_eq!(skills[0].content, first_content);
assert_eq!(skills[0].provider, SkillProvider::Agents);
assert_eq!(skills[1].path, second_path);
assert_eq!(skills[1].name, "second");
assert_eq!(skills[1].content, second_content);
}
#[test]
fn parse_project_skill_contents_classifies_foreign_encoded_provider_path() {
let path = LocalOrRemotePath::Remote(RemotePath::new(
HostId::new("test-host".to_string()),
StandardizedPath::try_new(r"C:\repo\.codex\skills\windows-skill\SKILL.md").unwrap(),
));
let content = remote_skill_content("windows-skill", "Windows skill", "Windows body");
let skills = parse_project_skill_contents(vec![(path.clone(), content)]);
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].path, path);
assert_eq!(skills[0].provider, SkillProvider::Codex);
}
// ============================================================================
// Tests for handle_repository_update
// ============================================================================
@@ -63,12 +138,13 @@ fn test_handle_repository_update_single_skill_added() {
let skill = create_skill_file(&temp_dir, "test", "Test skill", "Test content");
let update = RepositoryUpdate {
added: HashSet::from([TargetFile::new(skill.path.clone(), false)]),
added: HashSet::from([TargetFile::new(skill_local_path(&skill), false)]),
modified: HashSet::new(),
deleted: HashSet::new(),
moved: HashMap::new(),
commit_updated: false,
index_lock_detected: false,
remote_ref_updated: false,
};
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
@@ -85,6 +161,365 @@ fn test_handle_repository_update_single_skill_added() {
});
}
#[test]
fn test_removing_remote_project_repo_deletes_shared_cached_skill_paths() {
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 host = HostId::new("test-host".to_string());
let repo_id = RepositoryIdentifier::Remote(RemotePath::new(
host.clone(),
StandardizedPath::try_new("/repo").unwrap(),
));
let first_path = remote_skill_path(&host, "first");
let second_path = remote_skill_path(&host, "second");
skill_watcher_handle.update(&mut app, |watcher, _| {
watcher.project_skill_files_by_repo.insert(
repo_id.clone(),
HashSet::from([first_path.clone(), second_path.clone()]),
);
watcher.remove_project_skills_for_repo(&repo_id);
});
let SkillWatcherEvent::SkillsDeleted { mut paths } = rx.recv().await.unwrap() else {
panic!("Expected SkillsDeleted event");
};
paths.sort_by_key(LocalOrRemotePath::display_path);
let mut expected = vec![first_path, second_path];
expected.sort_by_key(LocalOrRemotePath::display_path);
assert_eq!(paths, expected);
skill_watcher_handle.read(&app, |watcher, _| {
assert!(!watcher.project_skill_files_by_repo.contains_key(&repo_id));
});
});
}
#[test]
fn test_stale_project_skill_refresh_result_is_ignored() {
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, "stale", "Stale skill", "Old content");
let repo_id = RepositoryIdentifier::try_local(temp_dir.path()).unwrap();
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
let stale_generation = skill_watcher.advance_project_skill_refresh_generation(&repo_id);
skill_watcher.advance_project_skill_refresh_generation(&repo_id);
skill_watcher.emit_project_skills_if_current(
&repo_id,
stale_generation,
vec![skill],
ctx,
);
});
assert!(rx.try_recv().is_err());
});
}
#[test]
fn test_removing_project_repo_invalidates_pending_refresh_result() {
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, "removed", "Removed skill", "Old content");
let repo_id = RepositoryIdentifier::try_local(temp_dir.path()).unwrap();
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
let pending_generation =
skill_watcher.advance_project_skill_refresh_generation(&repo_id);
skill_watcher.remove_project_skills_for_repo(&repo_id);
skill_watcher.emit_project_skills_if_current(
&repo_id,
pending_generation,
vec![skill],
ctx,
);
});
assert!(rx.try_recv().is_err());
});
}
#[test]
#[cfg(unix)]
fn test_refresh_project_skills_for_repo_loads_indexed_and_symlinked_skill_directories() {
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());
let repo_metadata_handle = app.add_singleton_model(RepoMetadataModel::new);
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
let repo_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
let indexed_skill = create_skill_file(
&repo_dir,
"indexed-skill",
"Indexed skill",
"Indexed content",
);
let target_skill = create_skill_file(
&target_dir,
"linked-skill",
"Linked skill",
"Linked content",
);
let repo = repo_dir.path().to_path_buf();
let symlink_parent = repo.join(".agents/skills");
fs::create_dir_all(&symlink_parent).unwrap();
let symlink_skill_dir = symlink_parent.join("linked-skill");
std::os::unix::fs::symlink(
target_skill.path.to_local_path().unwrap().parent().unwrap(),
&symlink_skill_dir,
)
.unwrap();
let mut expected_skill = target_skill;
expected_skill.path = LocalOrRemotePath::Local(symlink_skill_dir.join("SKILL.md"));
let repo_id = RepositoryIdentifier::try_local(&repo).unwrap();
let repo_key = StandardizedPath::try_from_local(&repo).unwrap();
repo_metadata_handle.update(&mut app, |model, ctx| {
model.insert_test_state(
repo_key.clone(),
project_state(&repo, Some(&indexed_skill)),
ctx,
);
let mut standing_results = project_standing_results(&repo, Some(&indexed_skill));
standing_results.insert_project_skill(StandingQueryContent::file(
StandardizedPath::try_from_local(&skill_local_path(&expected_skill)).unwrap(),
));
model.insert_test_standing_results(repo_key, standing_results, ctx);
});
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
skill_watcher.refresh_project_skills_for_repo(&repo_id, ctx);
});
let SkillWatcherEvent::SkillsAdded { mut skills } = rx.recv().await.unwrap() else {
panic!("Expected SkillsAdded event");
};
skills.sort_by_key(|skill| skill.path.display_path());
let mut expected = vec![indexed_skill, expected_skill];
expected.sort_by_key(|skill| skill.path.display_path());
assert_eq!(skills, expected);
});
}
#[test]
fn test_refresh_project_skills_for_repo_uses_repo_metadata_without_fallback_watcher() {
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());
let repo_metadata_handle = 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, "metadata-skill", "Metadata skill", "Content");
let repo = temp_dir.path().to_path_buf();
let repo_id = RepositoryIdentifier::try_local(&repo).unwrap();
let repo_key = StandardizedPath::try_from_local(&repo).unwrap();
repo_metadata_handle.update(&mut app, |model, ctx| {
model.insert_test_state(repo_key.clone(), project_state(&repo, Some(&skill)), ctx);
model.insert_test_standing_results(
repo_key,
project_standing_results(&repo, Some(&skill)),
ctx,
);
});
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
skill_watcher.refresh_project_skills_for_repo(&repo_id, ctx);
assert!(skill_watcher.failed_local_project_watchers.is_empty());
});
assert_eq!(
rx.recv().await.unwrap(),
SkillWatcherEvent::SkillsAdded {
skills: vec![skill]
}
);
});
}
#[test]
fn test_local_project_fallback_scans_filesystem_when_repo_metadata_fails() {
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 repo = dunce::canonicalize(temp_dir.path()).unwrap();
let root_skill =
create_skill_file_in_directory(&repo, "root-skill", "Root skill", "Root content");
let subdir = repo.join("packages/frontend");
let subdir_skill =
create_skill_file_in_directory(&subdir, "frontend-skill", "Frontend skill", "Content");
let repo_id = RepositoryIdentifier::try_local(&repo).unwrap();
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
skill_watcher.fallback_to_local_project_watcher(&repo_id, ctx);
assert!(skill_watcher.failed_local_project_watchers.is_empty());
});
let SkillWatcherEvent::SkillsAdded { mut skills } = rx.recv().await.unwrap() else {
panic!("Expected SkillsAdded event");
};
skills.sort_by_key(|skill| skill.path.display_path());
let mut expected = vec![root_skill, subdir_skill];
expected.sort_by_key(|skill| skill.path.display_path());
assert_eq!(skills, expected);
});
}
#[test]
#[cfg(unix)]
fn test_local_project_fallback_initial_scan_loads_symlinked_skill_directory() {
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 repo_dir = TempDir::new().unwrap();
let target_dir = TempDir::new().unwrap();
let target_skill = create_skill_file(
&target_dir,
"fallback-linked-skill",
"Fallback linked skill",
"Linked content",
);
let repo = dunce::canonicalize(repo_dir.path()).unwrap();
let symlink_parent = repo.join(".agents/skills");
fs::create_dir_all(&symlink_parent).unwrap();
let symlink_skill_dir = symlink_parent.join("fallback-linked-skill");
std::os::unix::fs::symlink(
skill_local_path(&target_skill).parent().unwrap(),
&symlink_skill_dir,
)
.unwrap();
let mut expected_skill = target_skill;
expected_skill.path = LocalOrRemotePath::Local(symlink_skill_dir.join("SKILL.md"));
let repo_id = RepositoryIdentifier::try_local(&repo).unwrap();
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
skill_watcher.fallback_to_local_project_watcher(&repo_id, ctx);
});
assert_eq!(
rx.recv().await.unwrap(),
SkillWatcherEvent::SkillsAdded {
skills: vec![expected_skill]
}
);
});
}
#[test]
fn test_local_project_fallback_update_reuses_repository_update_handler() {
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, "fallback-update", "Fallback update", "Content");
let update = RepositoryUpdate {
added: HashSet::new(),
modified: HashSet::from([TargetFile::new(skill_local_path(&skill), false)]),
deleted: HashSet::new(),
moved: HashMap::new(),
commit_updated: false,
index_lock_detected: false,
remote_ref_updated: false,
};
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
skill_watcher.handle_message(
SkillRepositoryMessage::ProjectRepositoryUpdate { update },
ctx,
);
});
assert_eq!(
rx.recv().await.unwrap(),
SkillWatcherEvent::SkillsAdded {
skills: vec![skill]
}
);
});
}
#[test]
fn test_local_project_fallback_directory_addition_scans_filesystem() {
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 new_dir = temp_dir.path().join("packages/frontend");
let skill =
create_skill_file_in_directory(&new_dir, "fallback-dir", "Fallback dir", "Content");
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,
remote_ref_updated: false,
};
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
skill_watcher.handle_message(
SkillRepositoryMessage::ProjectRepositoryUpdate { update },
ctx,
);
});
assert_eq!(
rx.recv().await.unwrap(),
SkillWatcherEvent::SkillsAdded {
skills: vec![skill]
}
);
});
}
#[test]
fn test_handle_repository_update_skill_modified() {
let (tx, rx) = async_channel::unbounded();
@@ -100,11 +535,12 @@ fn test_handle_repository_update_skill_modified() {
let update = RepositoryUpdate {
added: HashSet::new(),
modified: HashSet::from([TargetFile::new(skill.path.clone(), false)]),
modified: HashSet::from([TargetFile::new(skill_local_path(&skill), false)]),
deleted: HashSet::new(),
moved: HashMap::new(),
commit_updated: false,
index_lock_detected: false,
remote_ref_updated: false,
};
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
@@ -137,10 +573,11 @@ fn test_handle_repository_update_skill_deleted() {
let update = RepositoryUpdate {
added: HashSet::new(),
modified: HashSet::new(),
deleted: HashSet::from([TargetFile::new(skill.path.clone(), false)]),
deleted: HashSet::from([TargetFile::new(skill_local_path(&skill), false)]),
moved: HashMap::new(),
commit_updated: false,
index_lock_detected: false,
remote_ref_updated: false,
};
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
@@ -175,12 +612,13 @@ fn test_handle_repository_update_multiple_skills_deleted() {
added: HashSet::new(),
modified: HashSet::new(),
deleted: HashSet::from([
TargetFile::new(skill_a.path.clone(), false),
TargetFile::new(skill_b.path.clone(), false),
TargetFile::new(skill_local_path(&skill_a), false),
TargetFile::new(skill_local_path(&skill_b), false),
]),
moved: HashMap::new(),
commit_updated: false,
index_lock_detected: false,
remote_ref_updated: false,
};
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
@@ -191,9 +629,9 @@ fn test_handle_repository_update_multiple_skills_deleted() {
let SkillWatcherEvent::SkillsDeleted { mut paths } = event else {
panic!("Expected SkillsDeleted event");
};
paths.sort();
paths.sort_by_key(LocalOrRemotePath::display_path);
let mut expected = vec![skill_a.path, skill_b.path];
expected.sort();
expected.sort_by_key(LocalOrRemotePath::display_path);
assert_eq!(paths, expected);
});
}
@@ -218,11 +656,12 @@ fn test_handle_repository_update_skill_moved() {
modified: HashSet::new(),
deleted: HashSet::new(),
moved: HashMap::from([(
TargetFile::new(new_skill.path.clone(), false),
TargetFile::new(old_skill.path.clone(), false),
TargetFile::new(skill_local_path(&new_skill), false),
TargetFile::new(skill_local_path(&old_skill), false),
)]),
commit_updated: false,
index_lock_detected: false,
remote_ref_updated: false,
};
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
@@ -248,39 +687,21 @@ fn test_handle_repository_update_skill_moved() {
}
// ============================================================================
// Tests for handle_repository_update - directory addition
// Tests for project skill refreshes
// ============================================================================
/// 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() {
fn test_handle_repository_update_non_skill_directory_added_does_not_emit_project_event() {
let (tx, rx) = async_channel::unbounded();
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(|_| 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");
let new_dir = temp_dir.path().join("new-feature");
fs::create_dir_all(&new_dir).unwrap();
let update = RepositoryUpdate {
@@ -290,109 +711,127 @@ fn test_handle_repository_update_non_skill_directory_added_queues_project_direct
moved: HashMap::new(),
commit_updated: false,
index_lock_detected: false,
remote_ref_updated: 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()
assert!(rx.try_recv().is_err());
});
}
fn project_state(repo: &std::path::Path, skill: Option<&ParsedSkill>) -> FileTreeState {
let children = if let Some(skill) = skill {
let skill_path = skill_local_path(skill);
let skill_file = Entry::File(FileMetadata::new(skill_path.clone(), false));
let skill_dir = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(skill_path.parent().unwrap()).unwrap(),
children: vec![skill_file],
ignored: false,
loaded: true,
});
let skills_dir = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(&repo.join(".agents/skills")).unwrap(),
children: vec![skill_dir],
ignored: false,
loaded: true,
});
let agents_dir = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(&repo.join(".agents")).unwrap(),
children: vec![skills_dir],
ignored: false,
loaded: true,
});
vec![agents_dir]
} else {
Vec::new()
};
let root = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(repo).unwrap(),
children,
ignored: false,
loaded: true,
});
FileTreeState::new(root, Vec::new(), None)
}
fn project_standing_results(
repo: &std::path::Path,
skill: Option<&ParsedSkill>,
) -> StandingQueryResults {
let mut delta = StandingQueryResultsDelta {
upserted_project_skills: vec![StandingQueryContent::directory(
StandardizedPath::try_from_local(&repo.join(".agents/skills")).unwrap(),
)],
..StandingQueryResultsDelta::default()
};
if let Some(skill) = skill {
delta
.upserted_project_skills
.push(StandingQueryContent::file(
StandardizedPath::try_from_local(&skill_local_path(skill)).unwrap(),
));
}
let mut results = StandingQueryResults::default();
results.apply_delta(&delta);
results
}
#[test]
fn test_refresh_project_skills_for_repo_removes_missing_project_skill_paths() {
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());
let repo_metadata_handle = 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 repo = temp_dir.path().to_path_buf();
let repo_id = RepositoryIdentifier::try_local(&repo).unwrap();
let repo_key = StandardizedPath::try_from_local(&repo).unwrap();
repo_metadata_handle.update(&mut app, |model, ctx| {
model.insert_test_state(repo_key.clone(), project_state(&repo, Some(&skill)), ctx);
model.insert_test_standing_results(
repo_key.clone(),
project_standing_results(&repo, Some(&skill)),
ctx,
);
});
});
}
/// 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);
skill_watcher.refresh_project_skills_for_repo(&repo_id, 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);
assert_eq!(
rx.recv().await.unwrap(),
SkillWatcherEvent::SkillsAdded {
skills: vec![skill.clone()]
}
);
repo_metadata_handle.update(&mut app, |model, ctx| {
model.insert_test_state(repo_key.clone(), project_state(&repo, None), ctx);
model.insert_test_standing_results(
repo_key,
project_standing_results(&repo, None),
ctx,
);
});
});
}
/// 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);
skill_watcher.refresh_project_skills_for_repo(&repo_id, 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);
});
assert_eq!(
rx.recv().await.unwrap(),
SkillWatcherEvent::SkillsDeleted {
paths: vec![skill.path]
}
);
});
}
// ============================================================================
// Tests for handle_queued_project_directory_creations
// ============================================================================
+11 -10
View File
@@ -1,18 +1,20 @@
use std::pin::Pin;
use futures::Future;
use ai::skills::{read_skills, ParsedSkill, SKILL_PROVIDER_DEFINITIONS};
use async_channel::Sender;
use futures::Future;
use repo_metadata::repository::RepositorySubscriber;
use repo_metadata::{Repository, RepositoryUpdate};
use galaxyui::ModelContext;
use repo_metadata::{repository::RepositorySubscriber, Repository, RepositoryUpdate};
/// 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 },
/// Incremental file system updates from a local project fallback watcher.
ProjectRepositoryUpdate { update: RepositoryUpdate },
/// Incremental file system updates from a home provider directory.
HomeRepositoryUpdate { update: RepositoryUpdate },
/// File changes detected in a resolved symlink target directory.
SymlinkTargetUpdate { update: RepositoryUpdate },
}
@@ -28,9 +30,8 @@ impl RepositorySubscriber for ProjectSkillSubscriber {
_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.
// Initial fallback scans are triggered directly when repo metadata indexing fails.
// This subscriber only keeps failed local repos hot-reloaded afterward.
Box::pin(async {})
}
@@ -45,7 +46,7 @@ impl RepositorySubscriber for ProjectSkillSubscriber {
Box::pin(async move {
let _ = tx
.send(SkillRepositoryMessage::RepositoryUpdate { update })
.send(SkillRepositoryMessage::ProjectRepositoryUpdate { update })
.await;
})
}
@@ -132,7 +133,7 @@ impl RepositorySubscriber for HomeSkillSubscriber {
Box::pin(async move {
let _ = tx
.send(SkillRepositoryMessage::RepositoryUpdate { update })
.send(SkillRepositoryMessage::HomeRepositoryUpdate { update })
.await;
})
}
+122 -80
View File
@@ -1,60 +1,111 @@
use std::{
collections::HashSet,
path::{Path, PathBuf},
sync::LazyLock,
};
use std::path::{Path, PathBuf};
use ai::skills::{
home_skills_path, read_skills, ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS,
home_skills_path, parse_skill, provider_parent_directory_for_skills_root, read_skills,
ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS,
};
use anyhow::Error;
use repo_metadata::{RepoMetadataModel, RepositoryIdentifier};
use walkdir::{DirEntry, WalkDir};
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxy_util::remote_path::RemotePath;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::AppContext;
use regex::Regex;
use repo_metadata::{local_model::GetContentsArgs, RepoContent, RepoMetadataModel};
use crate::galaxy_managed_paths_watcher::galaxy_managed_skill_dirs;
/// Finds all skill directories in a repository by querying the RepoMetadataModel tree.
fn local_or_remote_path_for_repo_path(
repo_id: &RepositoryIdentifier,
path: &StandardizedPath,
) -> LocalOrRemotePath {
match repo_id {
RepositoryIdentifier::Local(_) => LocalOrRemotePath::Local(path.to_local_path_lossy()),
RepositoryIdentifier::Remote(remote) => {
LocalOrRemotePath::Remote(RemotePath::new(remote.host_id.clone(), path.clone()))
}
}
}
/// Finds project skill files from stored standing results.
///
/// Returns a list of paths to skill directories (e.g., `/repo/.agents/skills/`, `/repo/sub/.claude/skills/`).
pub fn find_skill_directories_in_tree(
repo_path: &Path,
/// Symlinked project skills are resolved while evaluating standing queries on the process that
/// owns the repository. This consumer treats those results as authoritative for both local and
/// remote repositories; direct filesystem discovery remains confined to metadata-failure fallback.
pub(super) fn find_project_skill_files_in_tree(
repo_id: &RepositoryIdentifier,
repo_metadata: &RepoMetadataModel,
ctx: &AppContext,
) -> Vec<PathBuf> {
// Collect provider skills paths (e.g., ".agents/skills", ".claude/skills")
let skill_path_suffixes: Vec<&Path> = SKILL_PROVIDER_DEFINITIONS
.iter()
.map(|p| p.skills_path.as_path())
.collect();
// Filter during traversal: only collect directories that end with a skill provider path.
// The filter rejects files and non-matching directories, avoiding intermediate allocations.
let args = GetContentsArgs::default().with_filter(move |content| {
let RepoContent::Directory(dir) = content else {
return false;
};
skill_path_suffixes
.iter()
.any(|suffix| dir.path.ends_with(&suffix.to_string_lossy()))
});
let Some(id) = repo_metadata::RepositoryIdentifier::try_local(repo_path) else {
return Vec::new();
};
) -> Vec<LocalOrRemotePath> {
repo_metadata
.get_repo_contents(&id, args, ctx)
.unwrap_or_default()
.standing_query_results(repo_id, ctx)
.into_iter()
// Only directories should reach this iterator due to the GetContentsArgs::filter.
// Keep the File arm for exhaustive matching in case RepoContent grows new variants.
.map(|content| match content {
RepoContent::Directory(dir) => dir.path.to_local_path_lossy(),
RepoContent::File(f) => f.path.to_local_path_lossy(),
.flat_map(|results| results.project_skills())
.filter(|content| !content.is_directory)
.map(|content| local_or_remote_path_for_repo_path(repo_id, &content.path))
.collect()
}
/// Finds local project skill files by discovering provider directories on the filesystem.
///
/// This is a local-only fallback for repositories whose repo metadata indexing fails. Successful
/// local and remote project refreshes should use [`find_project_skill_files_in_tree`] so the
/// normal metadata-backed path remains shared.
pub(super) fn find_local_project_skill_files_on_filesystem(
scan_root: &Path,
) -> Vec<LocalOrRemotePath> {
let direct_skill_file = scan_root.join("SKILL.md");
if is_skill_file(&direct_skill_file) {
return vec![LocalOrRemotePath::Local(direct_skill_file)];
}
find_local_provider_directories_on_filesystem(scan_root)
.into_iter()
.flat_map(|provider_dir| std::fs::read_dir(provider_dir).into_iter().flatten())
.filter_map(Result::ok)
.filter_map(|entry| {
let skill_dir = entry.path();
if !skill_dir.is_dir() {
return None;
}
let skill_file = skill_dir.join("SKILL.md");
skill_file
.exists()
.then_some(LocalOrRemotePath::Local(skill_file))
})
.collect()
}
fn find_local_provider_directories_on_filesystem(scan_root: &Path) -> Vec<PathBuf> {
let mut provider_dirs = Vec::new();
let mut entries = WalkDir::new(scan_root).follow_links(false).into_iter();
while let Some(entry) = entries.next() {
let Ok(entry) = entry else {
continue;
};
if is_ignored_fallback_scan_entry(&entry) {
if entry.file_type().is_dir() {
entries.skip_current_dir();
}
continue;
}
if entry.file_type().is_dir() && is_project_provider_path(entry.path()) {
provider_dirs.push(entry.into_path());
entries.skip_current_dir();
}
}
provider_dirs.sort();
provider_dirs
}
fn is_ignored_fallback_scan_entry(entry: &DirEntry) -> bool {
entry.file_name().to_str() == Some(".git")
}
fn is_project_provider_path(path: &Path) -> bool {
SKILL_PROVIDER_DEFINITIONS
.iter()
.any(|provider| path.ends_with(&provider.skills_path))
}
/// Reads all skills from the given skill directories.
pub fn read_skills_from_directories(
skill_dirs: impl IntoIterator<Item = PathBuf>,
@@ -64,60 +115,51 @@ pub fn read_skills_from_directories(
.flat_map(|dir| read_skills(&dir))
.collect()
}
pub fn is_skill_file(path: &Path) -> bool {
extract_skill_parent_directory(path).is_ok()
/// Reads all skills from the given concrete skill files.
pub fn read_skills_from_files(skill_files: impl IntoIterator<Item = PathBuf>) -> Vec<ParsedSkill> {
skill_files
.into_iter()
.filter_map(|path| parse_skill(&path).ok())
.collect()
}
static SKILL_PROVIDER_PATHS: LazyLock<HashSet<String>> = LazyLock::new(|| {
// Collect the skill provider paths from the definitions
SKILL_PROVIDER_DEFINITIONS
.iter()
.map(|p| p.skills_path.to_string_lossy().to_string())
.collect()
});
pub fn is_skill_file(path: &Path) -> bool {
extract_skill_parent_directory(&LocalOrRemotePath::Local(path.to_path_buf())).is_ok()
}
// Pattern: {prefix}/{provider_path}/{skill-name}/SKILL.md
// where provider_path is 2 parts (e.g., ".agents/skills") and skill-name is 1 part
#[cfg(not(target_os = "windows"))]
static SKILL_FILE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(.+)/([^/]+/[^/]+)/[^/]+/SKILL\.md$")
.expect("Failed to compile skill file pattern")
});
// On windows, the path separator is \
#[cfg(target_os = "windows")]
static SKILL_FILE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(.+)\\([^\\]+\\[^\\]+)\\[^\\]+\\SKILL\.md$")
.expect("Failed to compile skill file pattern")
});
pub fn extract_skill_parent_directory(path: &Path) -> Result<PathBuf, Error> {
pub fn extract_skill_parent_directory(
path: &LocalOrRemotePath,
) -> Result<LocalOrRemotePath, Error> {
let is_warp_home_skill = path
.file_name()
.to_local_path()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
.is_some_and(|name| name == "SKILL.md")
&& path
.parent()
.to_local_path()
.and_then(Path::parent)
.and_then(Path::parent)
.is_some_and(|parent| galaxy_managed_skill_dirs().iter().any(|dir| parent == dir));
if is_warp_home_skill {
return dirs::home_dir()
.ok_or_else(|| anyhow::anyhow!("Home directory not available for {}", path.display()));
.map(LocalOrRemotePath::Local)
.ok_or_else(|| {
anyhow::anyhow!("Home directory not available for {}", path.display_path())
});
}
let path_str = path.to_string_lossy();
if let Some(captures) = SKILL_FILE_PATTERN.captures(&path_str) {
if let Some(provider_path) = captures.get(2) {
if SKILL_PROVIDER_PATHS.contains(provider_path.as_str()) {
if let Some(parent_directory) = captures.get(1) {
return Ok(PathBuf::from(parent_directory.as_str()));
}
}
}
if path.file_name() != Some("SKILL.md") {
return Err(anyhow::anyhow!("Not a skill path: {}", path.display_path()));
}
Err(anyhow::anyhow!("Not a skill path: {}", path.display()))
let Some(skill_dir) = path.parent() else {
return Err(anyhow::anyhow!("Not a skill path: {}", path.display_path()));
};
let Some(skills_root) = skill_dir.parent() else {
return Err(anyhow::anyhow!("Not a skill path: {}", path.display_path()));
};
provider_parent_directory_for_skills_root(&skills_root)
.ok_or_else(|| anyhow::anyhow!("Not a skill path: {}", path.display_path()))
}
/// Check if this path is a skill directory under a home directory provider path
+304 -48
View File
@@ -1,16 +1,33 @@
use galaxyui::App;
use repo_metadata::entry::{DirectoryEntry, Entry, FileMetadata};
use repo_metadata::file_tree_store::FileTreeState;
use repo_metadata::file_tree_update::{
DirectoryNodeMetadata, FileNodeMetadata, FileTreeEntryUpdate, RepoNodeMetadata,
};
use repo_metadata::repositories::DetectedRepositories;
use repo_metadata::{
entry::{DirectoryEntry, Entry, FileMetadata},
file_tree_store::FileTreeState,
repositories::DetectedRepositories,
DirectoryWatcher, RepoMetadataModel,
DirectoryWatcher, RepoMetadataModel, RepoMetadataUpdate, RepositoryIdentifier,
StandingQueryContent, StandingQueryResults, StandingQueryResultsDelta,
};
use virtual_fs::{Stub, VirtualFS};
use galaxy_util::host_id::HostId;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxy_util::remote_path::RemotePath;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::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,
extract_skill_parent_directory, find_project_skill_files_in_tree, is_home_provider_path,
is_home_skill_directory, is_skill_file, read_skills_from_files,
};
fn project_standing_results(
skill_paths: impl IntoIterator<Item = StandardizedPath>,
) -> StandingQueryResults {
let mut results = StandingQueryResults::default();
for skill_path in skill_paths {
results.insert_project_skill(StandingQueryContent::file(skill_path));
}
results
}
// ============================================================================
// Tests for is_skill_file
@@ -144,8 +161,11 @@ fn extract_skill_parent_directory_from_repo_root() {
.join("skills")
.join("my-skill")
.join("SKILL.md");
let result = extract_skill_parent_directory(&skill_path);
assert_eq!(result.ok(), Some(parent_directory));
let result = extract_skill_parent_directory(&LocalOrRemotePath::Local(skill_path));
assert_eq!(
result.ok(),
Some(LocalOrRemotePath::Local(parent_directory))
);
}
#[test]
@@ -160,8 +180,11 @@ fn extract_skill_parent_directory_from_subdirectory() {
.join("skills")
.join("build")
.join("SKILL.md");
let result = extract_skill_parent_directory(&skill_path);
assert_eq!(result.ok(), Some(parent_directory));
let result = extract_skill_parent_directory(&LocalOrRemotePath::Local(skill_path));
assert_eq!(
result.ok(),
Some(LocalOrRemotePath::Local(parent_directory))
);
}
#[test]
@@ -181,10 +204,10 @@ fn extract_skill_parent_directory_from_deep_subdirectory() {
.join("skills")
.join("test-skill")
.join("SKILL.md");
let result = extract_skill_parent_directory(&skill_path);
let result = extract_skill_parent_directory(&LocalOrRemotePath::Local(skill_path.clone()));
assert_eq!(
result.ok(),
Some(parent_directory),
Some(LocalOrRemotePath::Local(parent_directory)),
"Failed for path: {}",
skill_path.display()
);
@@ -204,10 +227,10 @@ fn extract_skill_parent_directory_different_providers() {
.join("skills")
.join("s")
.join("SKILL.md");
let result = extract_skill_parent_directory(&path);
let result = extract_skill_parent_directory(&LocalOrRemotePath::Local(path.clone()));
assert_eq!(
result.ok(),
Some(repo.clone()),
Some(LocalOrRemotePath::Local(repo.clone())),
"Failed for path: {}",
path.display()
);
@@ -228,7 +251,10 @@ fn extract_skill_parent_directory_returns_none_for_non_skill() {
.join("skills")
.join("my-skill")
.join("README.md");
assert_eq!(extract_skill_parent_directory(&path).ok(), None);
assert_eq!(
extract_skill_parent_directory(&LocalOrRemotePath::Local(path)).ok(),
None
);
// Wrong structure (skill directly in skills dir)
let path = home_dir
@@ -236,7 +262,10 @@ fn extract_skill_parent_directory_returns_none_for_non_skill() {
.join(".agents")
.join("skills")
.join("SKILL.md");
assert_eq!(extract_skill_parent_directory(&path).ok(), None);
assert_eq!(
extract_skill_parent_directory(&LocalOrRemotePath::Local(path)).ok(),
None
);
// Too deeply nested
let path = home_dir
@@ -246,11 +275,17 @@ fn extract_skill_parent_directory_returns_none_for_non_skill() {
.join("a")
.join("b")
.join("SKILL.md");
assert_eq!(extract_skill_parent_directory(&path).ok(), None);
assert_eq!(
extract_skill_parent_directory(&LocalOrRemotePath::Local(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);
assert_eq!(
extract_skill_parent_directory(&LocalOrRemotePath::Local(path)).ok(),
None
);
}
// ============================================================================
@@ -355,9 +390,9 @@ fn extract_skill_parent_directory_returns_home_dir_for_warp_home_skill() {
return;
};
let skill_path = galaxy_home_skills_dir.join("test-skill").join("SKILL.md");
let result = extract_skill_parent_directory(&skill_path);
assert_eq!(result.ok(), Some(home_dir));
let skill_path = warp_home_skills_dir.join("test-skill").join("SKILL.md");
let result = extract_skill_parent_directory(&LocalOrRemotePath::Local(skill_path));
assert_eq!(result.ok(), Some(LocalOrRemotePath::Local(home_dir)));
}
#[test]
@@ -403,11 +438,11 @@ fn is_home_provider_path_false_for_partial_path() {
}
// ============================================================================
// Tests for find_skill_directories_in_tree
// Tests for find_skill_files_in_tree
// ============================================================================
#[test]
fn find_skill_directories_in_tree_finds_root_skills() {
fn find_skill_files_in_tree_finds_root_skills() {
VirtualFS::test("find_root_skills", |dirs, mut vfs| {
let repo = dirs.tests().join("repo");
@@ -513,20 +548,41 @@ fn find_skill_directories_in_tree_finds_root_skills() {
let model_handle = app.add_singleton_model(RepoMetadataModel::new);
model_handle.update(&mut app, |model, ctx| {
let key =
galaxy_util::standardized_path::StandardizedPath::from_local_canonicalized(
&repo,
)
.unwrap();
model.insert_test_state(key, state, ctx);
galaxy_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo)
.unwrap();
model.insert_test_state(key.clone(), state, ctx);
model.insert_test_standing_results(
key,
project_standing_results([
StandardizedPath::try_from_local(
&repo.join(".agents/skills/root-skill-1/SKILL.md"),
)
.unwrap(),
StandardizedPath::try_from_local(
&repo.join(".claude/skills/root-skill-2/SKILL.md"),
)
.unwrap(),
]),
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 repo_id = RepositoryIdentifier::try_local(&repo).unwrap();
let skill_files = find_project_skill_files_in_tree(&repo_id, model, ctx);
assert_eq!(skill_files.len(), 2);
assert!(skill_files.contains(&LocalOrRemotePath::Local(
repo.join(".agents/skills/root-skill-1/SKILL.md")
)));
assert!(skill_files.contains(&LocalOrRemotePath::Local(
repo.join(".claude/skills/root-skill-2/SKILL.md")
)));
let skills = read_skills_from_directories(skill_dirs);
let local_skill_files = skill_files
.into_iter()
.filter_map(|path| path.to_local_path().map(|path| path.to_path_buf()))
.collect::<Vec<_>>();
let skills = read_skills_from_files(local_skill_files);
assert_eq!(skills.len(), 2);
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"root-skill-1"));
@@ -537,7 +593,7 @@ fn find_skill_directories_in_tree_finds_root_skills() {
}
#[test]
fn find_skill_directories_in_tree_finds_subdirectory_skills() {
fn find_skill_files_in_tree_finds_subdirectory_skills() {
VirtualFS::test("find_subdir_skills", |dirs, mut vfs| {
let repo = dirs.tests().join("repo");
@@ -661,20 +717,41 @@ fn find_skill_directories_in_tree_finds_subdirectory_skills() {
let model_handle = app.add_singleton_model(RepoMetadataModel::new);
model_handle.update(&mut app, |model, ctx| {
let key =
galaxy_util::standardized_path::StandardizedPath::from_local_canonicalized(
&repo,
)
.unwrap();
model.insert_test_state(key, state, ctx);
galaxy_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo)
.unwrap();
model.insert_test_state(key.clone(), state, ctx);
model.insert_test_standing_results(
key,
project_standing_results([
StandardizedPath::try_from_local(
&repo.join(".agents/skills/root-skill/SKILL.md"),
)
.unwrap(),
StandardizedPath::try_from_local(
&repo.join("packages/frontend/.agents/skills/frontend-skill/SKILL.md"),
)
.unwrap(),
]),
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 repo_id = RepositoryIdentifier::try_local(&repo).unwrap();
let skill_files = find_project_skill_files_in_tree(&repo_id, model, ctx);
assert_eq!(skill_files.len(), 2);
assert!(skill_files.contains(&LocalOrRemotePath::Local(
repo.join(".agents/skills/root-skill/SKILL.md")
)));
assert!(skill_files.contains(&LocalOrRemotePath::Local(
repo.join("packages/frontend/.agents/skills/frontend-skill/SKILL.md")
)));
let skills = read_skills_from_directories(skill_dirs);
let local_skill_files = skill_files
.into_iter()
.filter_map(|path| path.to_local_path().map(|path| path.to_path_buf()))
.collect::<Vec<_>>();
let skills = read_skills_from_files(local_skill_files);
assert_eq!(skills.len(), 2);
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"root-skill"));
@@ -685,7 +762,185 @@ fn find_skill_directories_in_tree_finds_subdirectory_skills() {
}
#[test]
fn find_skill_directories_in_tree_empty_repo() {
fn find_skill_files_in_tree_returns_remote_skill_paths_for_remote_repos() {
App::test((), |mut app| async move {
app.add_singleton_model(|_| DetectedRepositories::default());
let model_handle = app.add_singleton_model(RepoMetadataModel::new);
let host_id = HostId::new("test-host".to_string());
let repo_path = StandardizedPath::try_new("/repo").unwrap();
let skill_path =
StandardizedPath::try_new("/repo/.agents/skills/remote-skill/SKILL.md").unwrap();
let repo_id =
RepositoryIdentifier::Remote(RemotePath::new(host_id.clone(), repo_path.clone()));
let update = RepoMetadataUpdate {
repo_path: repo_path.clone(),
remove_entries: vec![],
update_entries: vec![FileTreeEntryUpdate {
parent_path_to_replace: repo_path.clone(),
subtree_metadata: vec![
RepoNodeMetadata::Directory(DirectoryNodeMetadata {
path: StandardizedPath::try_new("/repo/.agents").unwrap(),
ignored: false,
loaded: true,
}),
RepoNodeMetadata::Directory(DirectoryNodeMetadata {
path: StandardizedPath::try_new("/repo/.agents/skills").unwrap(),
ignored: false,
loaded: true,
}),
RepoNodeMetadata::Directory(DirectoryNodeMetadata {
path: StandardizedPath::try_new("/repo/.agents/skills/remote-skill")
.unwrap(),
ignored: false,
loaded: true,
}),
RepoNodeMetadata::File(FileNodeMetadata {
path: skill_path.clone(),
extension: Some("md".to_string()),
ignored: false,
}),
],
}],
standing_results_delta: StandingQueryResultsDelta {
upserted_project_skills: vec![StandingQueryContent::file(skill_path.clone())],
..Default::default()
},
};
model_handle.update(&mut app, |model, ctx| {
model.insert_remote_snapshot(host_id.clone(), &update, ctx);
});
model_handle.read(&app, |model, ctx| {
let skill_files = find_project_skill_files_in_tree(&repo_id, model, ctx);
assert_eq!(
skill_files,
vec![LocalOrRemotePath::Remote(RemotePath::new(
host_id, skill_path
))]
);
});
});
}
#[cfg(unix)]
#[test]
fn find_skill_files_in_tree_does_not_rescan_local_provider_directories() {
VirtualFS::test(
"find_local_symlinked_skills_from_standing_results_only",
|dirs, mut vfs| {
vfs.mkdir("repo/.agents/skills")
.mkdir("target")
.with_files(vec![Stub::FileWithContent(
"target/SKILL.md",
"---\nname: linked\ndescription: test\n---\n# linked",
)]);
let repo = dirs.tests().join("repo");
let provider = repo.join(".agents/skills");
let linked_directory = provider.join("linked");
std::os::unix::fs::symlink(dirs.tests().join("target"), &linked_directory).unwrap();
App::test((), |mut app| async move {
app.add_singleton_model(|_| DetectedRepositories::default());
let model_handle = app.add_singleton_model(RepoMetadataModel::new);
model_handle.update(&mut app, |model, ctx| {
let key = StandardizedPath::from_local_canonicalized(&repo).unwrap();
let mut results = StandingQueryResults::default();
results.insert_project_skill(StandingQueryContent::directory(
StandardizedPath::try_from_local(&provider).unwrap(),
));
model.insert_test_standing_results(key, results, ctx);
});
model_handle.read(&app, |model, ctx| {
let repo_id = RepositoryIdentifier::try_local(&repo).unwrap();
assert!(find_project_skill_files_in_tree(&repo_id, model, ctx).is_empty());
});
});
},
);
}
#[test]
fn find_skill_files_in_tree_includes_ignored_skill_files() {
VirtualFS::test("find_skills_ignored", |dirs, mut vfs| {
let repo = dirs.tests().join("repo");
vfs.mkdir("repo/.agents/skills/ignored-skill")
.with_files(vec![Stub::FileWithContent(
"repo/.agents/skills/ignored-skill/SKILL.md",
"name: ignored-skill",
)]);
let skill_file = Entry::File(FileMetadata::new(
repo.join(".agents/skills/ignored-skill/SKILL.md"),
true,
));
let skill_dir = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(&repo.join(".agents/skills/ignored-skill"))
.unwrap(),
children: vec![skill_file],
ignored: true,
loaded: true,
});
let skills_dir = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(&repo.join(".agents/skills")).unwrap(),
children: vec![skill_dir],
ignored: true,
loaded: true,
});
let agents_dir = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(&repo.join(".agents")).unwrap(),
children: vec![skills_dir],
ignored: true,
loaded: true,
});
let root = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(&repo).unwrap(),
children: vec![agents_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(
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 = StandardizedPath::from_local_canonicalized(&repo).unwrap();
model.insert_test_state(key.clone(), state, ctx);
model.insert_test_standing_results(
key,
project_standing_results([StandardizedPath::try_from_local(
&repo.join(".agents/skills/ignored-skill/SKILL.md"),
)
.unwrap()]),
ctx,
);
});
model_handle.read(&app, |model, ctx| {
let repo_id = RepositoryIdentifier::try_local(&repo).unwrap();
assert_eq!(
find_project_skill_files_in_tree(&repo_id, model, ctx),
vec![LocalOrRemotePath::Local(
repo.join(".agents/skills/ignored-skill/SKILL.md")
)]
);
});
});
});
}
#[test]
fn find_skill_files_in_tree_empty_repo() {
VirtualFS::test("find_skills_empty", |dirs, mut vfs| {
let repo = dirs.tests().join("repo");
vfs.mkdir("repo/src");
@@ -732,8 +987,9 @@ fn find_skill_directories_in_tree_empty_repo() {
});
model_handle.read(&app, |model, ctx| {
let skill_dirs = find_skill_directories_in_tree(&repo, model, ctx);
assert!(skill_dirs.is_empty());
let repo_id = RepositoryIdentifier::try_local(&repo).unwrap();
let skill_files = find_project_skill_files_in_tree(&repo_id, model, ctx);
assert!(skill_files.is_empty());
});
});
});
+112
View File
@@ -0,0 +1,112 @@
//! Helpers for resolving per-agent "global" skill specs into repos to
//! ensure are available on disk before the agent runs.
use std::collections::{BTreeSet, HashMap, HashSet};
use std::str::FromStr;
use ai::skills::{provider_rank, ParsedSkill};
use warp_cli::skill::SkillSpec;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use crate::ai::cloud_environments::GithubRepo;
/// Parse raw skill spec strings and resolve the unique set of GitHub repos they reference.
///
/// Specs without an org/repo qualifier are not cloneable, so they are skipped
/// for repo resolution and left to normal on-disk skill discovery.
pub fn resolve_skill_repos(raw_specs: &[String]) -> (Vec<SkillSpec>, Vec<GithubRepo>) {
let specs: Vec<SkillSpec> = raw_specs
.iter()
.filter_map(|raw| match SkillSpec::from_str(raw) {
Ok(spec) => Some(spec),
Err(err) => {
log::warn!("Failed to parse global skill spec '{raw}': {err}");
None
}
})
.collect();
let mut seen = BTreeSet::new();
let mut repos = Vec::new();
for spec in &specs {
let (Some(org), Some(repo)) = (spec.org.as_ref(), spec.repo.as_ref()) else {
continue;
};
if seen.insert((org.clone(), repo.clone())) {
repos.push(GithubRepo::new(org.clone(), repo.clone()));
}
}
(specs, repos)
}
/// Returns the subset of skills that were explicitly requested by the given skill specs.
///
/// For simple skill names, this mirrors cached skill resolution by checking parsed skill names
/// in provider precedence order. For full-path specs, it matches the exact path relative to the
/// repo root.
pub fn filter_skills_by_spec(
repo_path: &LocalOrRemotePath,
skills: Vec<ParsedSkill>,
specs: &[SkillSpec],
) -> Vec<ParsedSkill> {
if specs.is_empty() || skills.is_empty() {
return Vec::new();
}
let skills_by_path = skills
.iter()
.map(|skill| (skill.path.clone(), skill))
.collect::<HashMap<_, _>>();
let mut selected_paths = Vec::new();
let mut seen_paths = HashSet::new();
for spec in specs {
if let Some(path) = matching_skill_path(repo_path, &skills_by_path, spec) {
if seen_paths.insert(path.clone()) {
selected_paths.push(path);
}
}
}
let selected_paths = selected_paths.into_iter().collect::<HashSet<_>>();
skills
.into_iter()
.filter(|skill| selected_paths.contains(&skill.path))
.collect()
}
fn matching_skill_path(
repo_path: &LocalOrRemotePath,
skills_by_path: &HashMap<LocalOrRemotePath, &ParsedSkill>,
spec: &SkillSpec,
) -> Option<LocalOrRemotePath> {
if spec.is_full_path() {
let path = repo_path.join(&spec.skill_identifier);
return skills_by_path.contains_key(&path).then_some(path);
}
matching_simple_skill_path(repo_path, skills_by_path, &spec.skill_identifier)
}
fn matching_simple_skill_path(
repo_path: &LocalOrRemotePath,
skills_by_path: &HashMap<LocalOrRemotePath, &ParsedSkill>,
skill_name: &str,
) -> Option<LocalOrRemotePath> {
let mut matches = skills_by_path
.values()
.copied()
.filter(|skill| skill.path.starts_with(repo_path) && skill.name == skill_name)
.collect::<Vec<_>>();
matches.sort_by(|left, right| {
provider_rank(left.provider)
.cmp(&provider_rank(right.provider))
.then_with(|| left.path.display_path().cmp(&right.path.display_path()))
});
matches.into_iter().map(|skill| skill.path.clone()).next()
}
#[cfg(test)]
#[path = "global_skills_tests.rs"]
mod tests;
+248
View File
@@ -0,0 +1,248 @@
use std::path::{Path, PathBuf};
use std::str::FromStr;
use ai::skills::{get_provider_for_path, ParsedSkill, SkillProvider, SkillScope};
use warp_cli::skill::SkillSpec;
use warp_util::host_id::HostId;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use warp_util::remote_path::RemotePath;
use warp_util::standardized_path::StandardizedPath;
use super::{filter_skills_by_spec, resolve_skill_repos};
use crate::ai::cloud_environments::GithubRepo;
#[test]
fn resolve_skill_repos_returns_empty_for_empty_input() {
let (specs, repos) = resolve_skill_repos(&[]);
assert!(specs.is_empty());
assert_eq!(repos, Vec::<GithubRepo>::new());
}
#[test]
fn resolve_skill_repos_skips_parse_failures() {
let (specs, repos) = resolve_skill_repos(&[
String::new(),
"warpdotdev/warp-internal:read-google-doc".to_string(),
]);
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].skill_identifier, "read-google-doc");
assert_eq!(
repos,
vec![GithubRepo::new(
"warpdotdev".to_string(),
"warp-internal".to_string(),
)]
);
}
#[test]
fn resolve_skill_repos_skips_unqualified_and_repo_only_specs() {
let (_specs, repos) = resolve_skill_repos(&[
"bare-name".to_string(),
".agents/skills/read-google-doc/SKILL.md".to_string(),
"warp-internal:read-google-doc".to_string(),
]);
assert_eq!(repos, Vec::<GithubRepo>::new());
}
#[test]
fn resolve_skill_repos_collects_org_qualified_repos() {
let (_specs, repos) = resolve_skill_repos(&[
"warpdotdev/warp-internal:read-google-doc".to_string(),
"warpdotdev/warp-server:deploy".to_string(),
]);
assert_eq!(
repos,
vec![
GithubRepo::new("warpdotdev".to_string(), "warp-internal".to_string()),
GithubRepo::new("warpdotdev".to_string(), "warp-server".to_string()),
]
);
}
#[test]
fn filter_skills_by_spec_only_loads_requested_simple_names() {
let repo_path = std::env::temp_dir().join("work").join("warp-internal");
let requested_skill_path = skill_path(&repo_path, ".agents", "read-google-doc");
let other_skill_path = skill_path(&repo_path, ".agents", "deploy");
let skills = vec![
parsed_skill(requested_skill_path.clone(), "read-google-doc"),
parsed_skill(other_skill_path, "deploy"),
];
let specs = global_specs(&["warpdotdev/warp-internal:read-google-doc".to_string()]);
let filtered = filter_skills_by_spec(&LocalOrRemotePath::Local(repo_path), skills, &specs);
assert_eq!(skill_paths(filtered), vec![requested_skill_path]);
}
#[test]
fn filter_skills_by_spec_matches_full_path_specs_for_remote_repos() {
let repo_path = remote_path("host-a", "/work/warp-internal");
let requested_skill_path = repo_path.join(".claude/skills/deploy/SKILL.md");
let other_host_skill_path =
remote_path("host-b", "/work/warp-internal").join(".claude/skills/deploy/SKILL.md");
let skills = vec![
parsed_skill_at_location(other_host_skill_path, "deploy", SkillProvider::Claude),
parsed_skill_at_location(
requested_skill_path.clone(),
"deploy",
SkillProvider::Claude,
),
];
let specs =
global_specs(&["warpdotdev/warp-internal:.claude/skills/deploy/SKILL.md".to_string()]);
let filtered = filter_skills_by_spec(&repo_path, skills, &specs);
assert_eq!(skill_locations(filtered), vec![requested_skill_path]);
}
#[test]
fn filter_skills_by_spec_scopes_simple_remote_names_to_the_repo_host() {
let repo_path = remote_path("host-a", "/work/warp-internal");
let requested_skill_path = repo_path.join(".claude/skills/deploy/SKILL.md");
let other_host_skill_path =
remote_path("host-b", "/work/warp-internal").join(".agents/skills/deploy/SKILL.md");
let skills = vec![
parsed_skill_at_location(other_host_skill_path, "deploy", SkillProvider::Agents),
parsed_skill_at_location(
requested_skill_path.clone(),
"deploy",
SkillProvider::Claude,
),
];
let specs = global_specs(&["warpdotdev/warp-internal:deploy".to_string()]);
let filtered = filter_skills_by_spec(&repo_path, skills, &specs);
assert_eq!(skill_locations(filtered), vec![requested_skill_path]);
}
#[test]
fn filter_skills_by_spec_matches_simple_names_by_parsed_skill_name() {
let repo_path = std::env::temp_dir().join("work").join("warp-internal");
let requested_skill_path = skill_path(&repo_path, ".agents", "google-doc");
let directory_name_match_path = skill_path(&repo_path, ".agents", "read-google-doc");
let skills = vec![
parsed_skill(requested_skill_path.clone(), "read-google-doc"),
parsed_skill(directory_name_match_path, "unrelated-skill"),
];
let specs = global_specs(&["warpdotdev/warp-internal:read-google-doc".to_string()]);
let filtered = filter_skills_by_spec(&LocalOrRemotePath::Local(repo_path), skills, &specs);
assert_eq!(skill_paths(filtered), vec![requested_skill_path]);
}
#[test]
fn filter_skills_by_spec_uses_provider_precedence_for_simple_names() {
let repo_path = std::env::temp_dir().join("work").join("warp-internal");
let agents_skill_path = skill_path(&repo_path, ".agents", "deploy");
let claude_skill_path = skill_path(&repo_path, ".claude", "deploy");
let skills = vec![
parsed_skill(claude_skill_path, "deploy"),
parsed_skill(agents_skill_path.clone(), "deploy"),
];
let specs = global_specs(&["warpdotdev/warp-internal:deploy".to_string()]);
let filtered = filter_skills_by_spec(&LocalOrRemotePath::Local(repo_path), skills, &specs);
assert_eq!(skill_paths(filtered), vec![agents_skill_path]);
}
#[test]
fn filter_skills_by_spec_matches_full_path_specs() {
let repo_path = std::env::temp_dir().join("work").join("warp-internal");
let requested_relative_path = PathBuf::from(".claude")
.join("skills")
.join("deploy")
.join("SKILL.md");
let requested_skill_path = repo_path.join(&requested_relative_path);
let other_skill_path = skill_path(&repo_path, ".agents", "deploy");
let skills = vec![
parsed_skill(other_skill_path, "deploy"),
parsed_skill(requested_skill_path.clone(), "deploy-from-full-path"),
];
let specs = global_specs(&[format!(
"warpdotdev/warp-internal:{}",
requested_relative_path.display()
)]);
let filtered = filter_skills_by_spec(&LocalOrRemotePath::Local(repo_path), skills, &specs);
assert_eq!(skill_paths(filtered), vec![requested_skill_path]);
}
fn global_specs(raw_specs: &[String]) -> Vec<SkillSpec> {
raw_specs
.iter()
.map(|raw| SkillSpec::from_str(raw).unwrap())
.collect()
}
fn skill_path(repo_path: &Path, provider_dir: &str, skill_name: &str) -> PathBuf {
repo_path
.join(provider_dir)
.join("skills")
.join(skill_name)
.join("SKILL.md")
}
fn parsed_skill(path: PathBuf, name: &str) -> ParsedSkill {
let path = LocalOrRemotePath::Local(path);
let provider = get_provider_for_path(&path).unwrap_or(SkillProvider::Agents);
parsed_skill_at_location(path, name, provider)
}
fn parsed_skill_at_location(
path: LocalOrRemotePath,
name: &str,
provider: SkillProvider,
) -> ParsedSkill {
ParsedSkill {
path,
name: name.to_string(),
description: String::new(),
content: String::new(),
line_range: None,
provider,
scope: SkillScope::Project,
}
}
fn skill_paths(skills: Vec<ParsedSkill>) -> Vec<PathBuf> {
skills
.into_iter()
.map(|skill| skill.path.to_local_path().unwrap().to_path_buf())
.collect()
}
fn skill_locations(skills: Vec<ParsedSkill>) -> Vec<LocalOrRemotePath> {
skills.into_iter().map(|skill| skill.path).collect()
}
fn remote_path(host_id: &str, path: &str) -> LocalOrRemotePath {
LocalOrRemotePath::Remote(RemotePath::new(
HostId::new(host_id.to_string()),
StandardizedPath::try_new(path).unwrap(),
))
}
#[test]
fn resolve_skill_repos_collapses_duplicates_preserving_first_seen_order() {
let (_specs, repos) = resolve_skill_repos(&[
"org-b/foo:first".to_string(),
"org-a/foo:second".to_string(),
"org-b/foo:third".to_string(),
]);
assert_eq!(
repos,
vec![
GithubRepo::new("org-b".to_string(), "foo".to_string()),
GithubRepo::new("org-a".to_string(), "foo".to_string()),
]
);
}
+70 -2
View File
@@ -1,5 +1,18 @@
use std::path::{Path, PathBuf};
use ai::skills::SkillPathOrigin;
use warp_util::local_or_remote_path::LocalOrRemotePath;
mod telemetry;
pub use telemetry::{SkillOpenOrigin, SkillTelemetryEvent};
#[cfg(all(not(target_family = "wasm"), feature = "local_fs"))]
mod remote;
#[cfg(all(not(target_family = "wasm"), feature = "local_fs"))]
pub(crate) use remote::bundled_skill_snapshot_protos;
#[cfg(feature = "local_fs")]
mod bundled;
#[cfg(all(not(target_family = "wasm"), feature = "local_fs"))]
pub(crate) use bundled::{BundledSkill, BundledSkillActivation};
cfg_if::cfg_if! {
if #[cfg(not(feature = "local_fs"))] {
@@ -10,14 +23,67 @@ cfg_if::cfg_if! {
pub use ai::skills::SkillReference;
#[cfg_attr(target_family = "wasm", allow(dead_code))]
pub enum SkillManagerEvent {
HomeSkillsChanged,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ActiveSkillLookupError {
#[error("Bundled skills are not available on this remote session")]
BundledSkillsUnavailable,
#[error("Skill not found: {reference}")]
NotFound { reference: SkillReference },
}
impl ActiveSkillLookupError {
pub(crate) fn for_reference(reference: &SkillReference, path_origin: &SkillPathOrigin) -> Self {
if matches!(path_origin, SkillPathOrigin::Unavailable)
&& matches!(reference, SkillReference::BundledSkillId(_))
{
Self::BundledSkillsUnavailable
} else {
Self::NotFound {
reference: reference.clone(),
}
}
}
}
#[cfg(not(target_family = "wasm"))]
mod global_skills;
#[cfg(not(target_family = "wasm"))]
pub use global_skills::{filter_skills_by_spec, resolve_skill_repos};
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,
skill_path_from_location,
};
pub trait SkillPathQuery {
fn to_skill_location(&self) -> LocalOrRemotePath;
}
impl SkillPathQuery for LocalOrRemotePath {
fn to_skill_location(&self) -> LocalOrRemotePath {
self.clone()
}
}
impl SkillPathQuery for Path {
fn to_skill_location(&self) -> LocalOrRemotePath {
LocalOrRemotePath::Local(self.to_path_buf())
}
}
impl SkillPathQuery for PathBuf {
fn to_skill_location(&self) -> LocalOrRemotePath {
LocalOrRemotePath::Local(self.clone())
}
}
#[cfg(not(target_family = "wasm"))]
mod resolve_skill_spec;
@@ -29,6 +95,8 @@ pub use resolve_skill_spec::{
cfg_if::cfg_if! {
if #[cfg(feature = "local_fs")] {
mod skill_manager;
pub use skill_manager::{SkillManager, SkillWatcher};
pub use skill_manager::{
read_skills_from_directories, SkillManager, SkillWatcher,
};
}
}
+57
View File
@@ -0,0 +1,57 @@
use remote_server::proto::{remote_skill_proto, BundledSkillMetadata, RemoteSkillProto};
use super::bundled::{BundledSkill, BundledSkillActivation};
use crate::ai::mcp::McpIntegration;
/// Stable wire identifier for an MCP integration in [`BundledSkillMetadata`].
fn mcp_integration_wire_id(integration: McpIntegration) -> &'static str {
match integration {
McpIntegration::Figma => "figma",
}
}
/// Serializes a daemon-side bundled catalog for the aggregate remote Agent Mode snapshot.
///
/// `RequiresFile` activations are evaluated here — the daemon owns the
/// files — so the client only ever receives `Always` or `RequiresMcp`
/// conditions. The result is sorted by skill path so pushes are
/// deterministic across daemon restarts.
pub(crate) fn bundled_skill_snapshot_protos(catalog: &BundledSkill) -> Vec<RemoteSkillProto> {
let mut protos: Vec<RemoteSkillProto> = catalog
.iter_definitions()
.filter_map(|(id, skill, activation)| {
let requires_mcp = match activation {
BundledSkillActivation::Always => None,
BundledSkillActivation::RequiresMcp(integration) => {
Some(mcp_integration_wire_id(*integration).to_owned())
}
BundledSkillActivation::RequiresFeature(feature) => {
if !feature.is_enabled() {
return None;
}
None
}
BundledSkillActivation::RequiresFile(path) => {
if !path.exists() {
return None;
}
None
}
};
Some(RemoteSkillProto {
path: skill.path.display_path(),
content: skill.content.clone(),
source: Some(remote_skill_proto::Source::Bundled(BundledSkillMetadata {
id: id.to_owned(),
requires_mcp,
})),
})
})
.collect();
protos.sort_by(|a, b| a.path.cmp(&b.path));
protos
}
#[cfg(test)]
#[path = "remote_tests.rs"]
mod tests;
+88
View File
@@ -0,0 +1,88 @@
use ai::skills::{ParsedSkill, SkillProvider, SkillScope};
use tempfile::TempDir;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use super::*;
fn daemon_skill(id: &str, content: &str) -> ParsedSkill {
ParsedSkill {
name: id.to_string(),
description: format!("{id} description"),
path: LocalOrRemotePath::Local(format!("/daemon/bundled/skills/{id}/SKILL.md").into()),
content: content.to_string(),
line_range: None,
provider: SkillProvider::Warp,
scope: SkillScope::Bundled,
}
}
fn bundled_metadata(proto: &RemoteSkillProto) -> &BundledSkillMetadata {
let Some(remote_skill_proto::Source::Bundled(metadata)) = proto.source.as_ref() else {
panic!("expected bundled skill metadata");
};
metadata
}
#[test]
fn snapshot_protos_serialize_activation_conditions() {
let temp_dir = TempDir::new().unwrap();
let present_file = temp_dir.path().join("settings_schema.json");
std::fs::write(&present_file, "{}").unwrap();
let missing_file = temp_dir.path().join("missing.json");
let catalog = BundledSkill::from_definitions([
(
"always-skill".to_string(),
daemon_skill("always-skill", "# always"),
BundledSkillActivation::Always,
),
(
"figma-skill".to_string(),
daemon_skill("figma-skill", "# figma"),
BundledSkillActivation::RequiresMcp(McpIntegration::Figma),
),
(
"file-present-skill".to_string(),
daemon_skill("file-present-skill", "# file"),
BundledSkillActivation::RequiresFile(present_file),
),
(
"file-missing-skill".to_string(),
daemon_skill("file-missing-skill", "# missing"),
BundledSkillActivation::RequiresFile(missing_file),
),
]);
let protos = bundled_skill_snapshot_protos(&catalog);
// `RequiresFile` is evaluated daemon-side: the missing-file skill is
// dropped, the present-file skill ships as unconditionally active.
let mut ids: Vec<&str> = protos
.iter()
.map(|proto| bundled_metadata(proto).id.as_str())
.collect();
ids.sort_unstable();
assert_eq!(ids, ["always-skill", "figma-skill", "file-present-skill"]);
let figma = protos
.iter()
.find(|proto| bundled_metadata(proto).id == "figma-skill")
.unwrap();
assert_eq!(
bundled_metadata(figma).requires_mcp.as_deref(),
Some("figma")
);
for proto in protos
.iter()
.filter(|proto| bundled_metadata(proto).id != "figma-skill")
{
assert_eq!(bundled_metadata(proto).requires_mcp, None);
}
let always = protos
.iter()
.find(|proto| bundled_metadata(proto).id == "always-skill")
.unwrap();
assert_eq!(always.path, "/daemon/bundled/skills/always-skill/SKILL.md");
assert_eq!(always.content, "# always");
}
+11 -5
View File
@@ -19,8 +19,8 @@ use ai::skills::{
use command::blocking::Command;
use command::r#async::Command as AsyncCommand;
use galaxy_cli::skill::SkillSpec;
use galaxyui::AppContext;
use galaxyui::SingletonEntity as _;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxyui::{AppContext, SingletonEntity as _};
use super::SkillManager;
use crate::galaxy_managed_paths_watcher::galaxy_managed_skill_dirs;
@@ -302,7 +302,7 @@ fn resolve_unqualified(
let home_matches: Vec<PathBuf> = all_matching_paths
.iter()
.filter(|p| home_skill_paths.contains(p))
.cloned()
.filter_map(|p| p.to_local_path().map(Path::to_path_buf))
.collect();
if let Some(skill_path) = best_match_by_directory_precedence(home_matches, home_dir.as_deref())
@@ -319,7 +319,8 @@ fn resolve_unqualified(
// 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);
.get_root_for_path(&LocalOrRemotePath::Local(working_dir.to_path_buf()))
.and_then(|r| PathBuf::try_from(r).ok());
if let Some(repo_root) = repo_root {
match resolve_in_single_repo_root(spec, &repo_root, skill_manager) {
@@ -332,6 +333,7 @@ fn resolve_unqualified(
// 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_map(|path| path.to_local_path().map(Path::to_path_buf))
.filter(|p| {
// Only include project skills (not home skills) that are under working_dir
skill_manager.skill_paths_in_scope(working_dir).contains(p)
@@ -383,6 +385,7 @@ fn resolve_in_single_repo_root(
let cached_paths: Vec<PathBuf> = skill_manager
.skill_paths_by_name(&spec.skill_identifier)
.into_iter()
.filter_map(|path| path.to_local_path().map(Path::to_path_buf))
.filter(|p| repo_skill_paths.contains(p))
.collect();
@@ -453,7 +456,10 @@ 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() {
if let Some(parsed) = skill_manager
.skill_by_path(&LocalOrRemotePath::Local(skill_path.to_path_buf()))
.cloned()
{
return Ok(parsed);
}
@@ -1,4 +1,5 @@
use std::{fs, path::Path};
use std::fs;
use std::path::Path;
use anyhow::{Context as _, Result};
use galaxy_cli::skill::SkillSpec;
+362 -296
View File
@@ -1,56 +1,27 @@
#[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};
use std::path::{Path, PathBuf};
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,
provider_rank, ParsedSkill, SkillPathOrigin, SkillProvider, SkillReference, SkillScope,
};
use galaxy_core::{
channel::ChannelState, features::FeatureFlag, report_error, safe_warn, ui::icons::Icon,
pub use file_watchers::{
extract_skill_parent_directory, read_skills_from_directories, SkillWatcher, SkillWatcherEvent,
};
use galaxy_core::features::FeatureFlag;
use galaxy_util::host_id::HostId;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
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,
}
#[cfg(test)]
use super::bundled::{
activation_for_bundled_skill, build_bundled_skill_context, read_bundled_skills,
BundledSkillActivation,
};
use super::bundled::{BundledSkill, BundledSkills};
use super::{ActiveSkillLookupError, SkillDescriptor, SkillManagerEvent, SkillPathQuery};
use crate::ai::skills::skill_utils::SkillDeduplicator;
pub struct SkillManager {
/// Maps a directory path to the set of skill file paths defined in that directory.
@@ -64,13 +35,18 @@ pub struct SkillManager {
///
/// NOT:
/// - Key: `/repo/frontend/.agents/skills`
directory_skills: HashMap<PathBuf, HashSet<PathBuf>>,
skills_by_path: HashMap<PathBuf, ParsedSkill>,
directory_skills: HashMap<LocalOrRemotePath, HashSet<LocalOrRemotePath>>,
skills_by_path: HashMap<LocalOrRemotePath, 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>,
skills_by_name: HashMap<String, HashSet<LocalOrRemotePath>>,
/// Skills bundled into Warp for the local host and connected remote hosts.
bundled_skills: BundledSkills,
/// Home directories published by connected remote hosts.
///
/// Remote home skills themselves live in the shared file-skill indexes above,
/// alongside local home and project skills.
remote_home_directories: HashMap<HostId, LocalOrRemotePath>,
/// 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
@@ -86,8 +62,8 @@ impl SkillManager {
ctx.spawn_stream_local(
skill_watcher_rx,
|me, message, _ctx| {
me.handle_skill_watcher_event(message);
|me, message, ctx| {
me.handle_skill_watcher_event(message, ctx);
},
|_, _| {}, // No cleanup needed when stream ends
);
@@ -96,11 +72,8 @@ impl SkillManager {
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);
ctx.spawn(BundledSkill::detect(), |me, result, _| {
me.bundled_skills.set_local(result);
});
}
@@ -108,7 +81,8 @@ impl SkillManager {
directory_skills: HashMap::new(),
skills_by_path: HashMap::new(),
skills_by_name: HashMap::new(),
bundled_skills: HashMap::new(),
bundled_skills: BundledSkills::default(),
remote_home_directories: HashMap::new(),
is_cloud_environment: false,
skill_watcher,
}
@@ -123,26 +97,57 @@ impl SkillManager {
/// Returns skills available for the given working directory.
pub fn get_skills_for_working_directory(
&self,
working_directory: Option<&Path>,
working_directory: Option<&LocalOrRemotePath>,
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();
let path_origin = match working_directory {
Some(LocalOrRemotePath::Remote(path)) => SkillPathOrigin::Remote {
host_id: path.host_id.clone(),
},
Some(LocalOrRemotePath::Local(_)) | None => SkillPathOrigin::Local,
};
self.get_skills_for_working_directory_with_origin(working_directory, &path_origin, ctx)
}
if let Some(home_dir) = dirs::home_dir() {
skill_paths.extend(
self.home_skill_paths()
.into_iter()
.map(|path| (home_dir.clone(), path)),
);
/// Returns skills available for the given working directory and execution host.
pub fn get_skills_for_working_directory_with_origin(
&self,
working_directory: Option<&LocalOrRemotePath>,
path_origin: &SkillPathOrigin,
ctx: &AppContext,
) -> Vec<SkillDescriptor> {
// Collect file-backed skills for one shared deduplication pass. Home skills use
// the home directory as their dir_path; project skills use their owning directory.
let mut skill_paths = Vec::new();
let mut deduplicator = SkillDeduplicator::default();
let path_matches_location = |path: &LocalOrRemotePath| match (working_directory, path) {
(Some(LocalOrRemotePath::Local(_)), LocalOrRemotePath::Local(_)) => true,
(
Some(LocalOrRemotePath::Remote(working_directory)),
LocalOrRemotePath::Remote(path),
) => working_directory.host_id == path.host_id,
(None, LocalOrRemotePath::Local(_)) => self.is_cloud_environment,
(Some(LocalOrRemotePath::Local(_)), LocalOrRemotePath::Remote(_))
| (Some(LocalOrRemotePath::Remote(_)), LocalOrRemotePath::Local(_))
| (None, LocalOrRemotePath::Remote(_)) => false,
};
if let Some(home_dir) = self.home_directory_for_origin(path_origin) {
if let Some(home_skill_paths) = self.directory_skills.get(&home_dir) {
skill_paths.extend(
home_skill_paths
.iter()
.cloned()
.map(|path| (home_dir.clone(), path)),
);
}
}
if self.is_cloud_environment {
// In cloud environments, all skills are in scope regardless of cwd.
// In cloud environments, all skills in the working directory's location are in scope
// regardless of cwd.
for (dir, dir_skill_paths) in &self.directory_skills {
if is_home_directory(dir) {
if self.is_home_directory(dir) || !path_matches_location(dir) {
continue;
}
for path in dir_skill_paths {
@@ -154,7 +159,7 @@ impl SkillManager {
.get_root_for_path(working_directory);
for (dir, dir_skill_paths) in &self.directory_skills {
if is_home_directory(dir) {
if self.is_home_directory(dir) {
continue;
}
// Only include skills from directories that are ancestors of the working directory
@@ -173,7 +178,8 @@ impl SkillManager {
// 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);
deduplicator.extend_paths(&skill_paths, &self.skills_by_path);
let mut skills = deduplicator.into_descriptors();
// Apply icon overrides for well-known skill names (e.g. partner integrations).
for skill in &mut skills {
@@ -183,28 +189,22 @@ impl SkillManager {
}
}
// Append bundled skills whose activation condition is met.
// Append bundled skills whose activation condition is met, from the
// catalog of the active execution host: SSH sessions see the remote
// daemon's catalog (empty until its snapshot arrives),
// never the local client's. Remote catalog descriptors are referenced
// by their remote paths so invocation resolves back to the same host's
// catalog, while direct `BundledSkillId` lookups use `path_origin`.
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.extend(self.bundled_skills.active_descriptors(path_origin, ctx));
}
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 {
pub fn home_skill_paths(&self) -> Vec<LocalOrRemotePath> {
let Some(home_dir) = self.home_directory_for_origin(&SkillPathOrigin::Local) else {
return vec![];
};
self.directory_skills
@@ -213,10 +213,24 @@ impl SkillManager {
.unwrap_or_default()
}
/// Returns the parsed home skills currently cached by the local watcher.
pub fn home_skills(&self) -> impl Iterator<Item = &ParsedSkill> + '_ {
dirs::home_dir()
.map(LocalOrRemotePath::Local)
.into_iter()
.filter_map(|home_dir| self.directory_skills.get(&home_dir))
.flatten()
.filter_map(|path| self.skills_by_path.get(path))
}
/// 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();
let mut dirs: Vec<PathBuf> = self
.directory_skills
.keys()
.filter_map(|path| path.to_local_path().map(Path::to_path_buf))
.collect();
dirs.sort();
dirs
}
@@ -232,11 +246,16 @@ impl SkillManager {
/// Both will be returned.
pub fn skill_paths_in_scope(&self, scope_dir: &Path) -> Vec<PathBuf> {
let mut paths = HashSet::new();
let scope_dir = LocalOrRemotePath::Local(scope_dir.to_path_buf());
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());
if dir.starts_with(&scope_dir) {
paths.extend(
skill_paths
.iter()
.filter_map(|path| path.to_local_path().map(Path::to_path_buf)),
);
}
}
@@ -260,9 +279,7 @@ impl SkillManager {
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))
self.providers_for_descriptor(skill)
.any(|provider| providers.contains(&provider))
}
@@ -287,62 +304,244 @@ impl SkillManager {
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))
self.providers_for_descriptor(skill)
.filter(|provider| supported_providers.contains(provider))
.min_by_key(|provider| provider_rank(*provider))
.unwrap_or(skill.provider)
}
fn providers_for_descriptor<'a>(
&'a self,
descriptor: &'a SkillDescriptor,
) -> impl Iterator<Item = SkillProvider> + 'a {
self.skills_by_name
.get(&descriptor.name)
.into_iter()
.flatten()
.filter(|path| path_matches_reference_location(path, &descriptor.reference))
.filter_map(|path| self.skills_by_path.get(path).map(|skill| 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> {
pub fn skill_paths_by_name(&self, name: &str) -> Vec<LocalOrRemotePath> {
self.skills_by_name
.get(name)
.map(|paths| {
let mut paths: Vec<PathBuf> = paths.iter().cloned().collect();
paths.sort();
let mut paths: Vec<LocalOrRemotePath> = paths.iter().cloned().collect();
paths.sort_by_key(LocalOrRemotePath::display_path);
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)
/// Falls through to the remote bundled catalog, whose skills are addressed by path.
pub fn skill_by_path<P: SkillPathQuery + ?Sized>(
&self,
skill_path: &P,
) -> Option<&ParsedSkill> {
let location = skill_path.to_skill_location();
self.skill_by_location(&location)
}
/// 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 {
pub fn reference_for_skill_path<P: SkillPathQuery + ?Sized>(
&self,
skill_path: &P,
) -> SkillReference {
let skill_path = skill_path.to_skill_location();
// 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());
}
if let Some(reference) = self.bundled_skills.reference_for_path(&skill_path) {
return reference;
}
// Default to path-based reference.
SkillReference::Path(skill_path.to_path_buf())
SkillReference::Path(skill_path)
}
/// 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)
}
SkillReference::Path(path) => self.skill_by_location(path),
SkillReference::BundledSkillId(id) => self.bundled_skills.local_skill(id),
}
}
/// 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)
/// Get the definition of a skill only if it is currently available for invocation.
///
/// Path-based user skills are always controlled by normal path scoping. Bundled
/// skills (the local catalog's ID-addressed entries and remote catalogs'
/// path-addressed entries) additionally respect their runtime activation
/// state so stale references cannot invoke disabled bundled skills.
pub fn active_skill_by_reference(
&self,
reference: &SkillReference,
ctx: &AppContext,
) -> Option<&ParsedSkill> {
self.active_skill_by_reference_with_origin(reference, &SkillPathOrigin::Local, ctx)
.ok()
}
fn handle_skill_watcher_event(&mut self, event: SkillWatcherEvent) {
/// Get the definition of a skill for the selected execution host only if it is active.
pub fn active_skill_by_reference_with_origin(
&self,
reference: &SkillReference,
path_origin: &SkillPathOrigin,
ctx: &AppContext,
) -> Result<&ParsedSkill, ActiveSkillLookupError> {
let skill = match reference {
SkillReference::Path(path) => self.skills_by_path.get(path).or_else(|| {
let remote = path.as_remote()?;
let SkillPathOrigin::Remote { host_id } = path_origin else {
return None;
};
if remote.host_id != *host_id {
return None;
}
self.bundled_skills.remote_active_skill_by_path(remote, ctx)
}),
SkillReference::BundledSkillId(id) => {
self.bundled_skills.active_skill(id, path_origin, ctx)
}
};
skill.ok_or_else(|| ActiveSkillLookupError::for_reference(reference, path_origin))
}
/// Returns a local bundled skill by ID only if its activation condition is met.
pub fn active_local_bundled_skill(&self, id: &str, ctx: &AppContext) -> Option<&ParsedSkill> {
self.bundled_skills
.active_skill(id, &SkillPathOrigin::Local, ctx)
}
pub(super) fn set_remote_bundled_skill(
&mut self,
host_id: HostId,
bundled_skill: BundledSkill,
) {
self.bundled_skills.insert_remote(host_id, bundled_skill);
}
pub(super) fn remove_remote_bundled_skill(&mut self, host_id: &HostId) {
self.bundled_skills.remove_remote(host_id);
}
pub(crate) fn replace_remote_agent_context(
&mut self,
host_id: HostId,
bundled_skills: Option<BundledSkill>,
home_skills: Option<(LocalOrRemotePath, Vec<ParsedSkill>)>,
) {
match bundled_skills {
Some(bundled_skills) => {
self.set_remote_bundled_skill(host_id.clone(), bundled_skills);
}
None => self.remove_remote_bundled_skill(&host_id),
}
match home_skills {
Some((home_dir, skills)) => {
self.set_remote_home_skills(host_id, home_dir, skills);
}
None => self.remove_remote_home_skills(&host_id),
}
}
pub(crate) fn remove_remote_agent_context(&mut self, host_id: &HostId) {
self.remove_remote_bundled_skill(host_id);
self.remove_remote_home_skills(host_id);
}
/// Replaces the home skills published by one remote host.
pub(crate) fn set_remote_home_skills(
&mut self,
host_id: HostId,
home_dir: LocalOrRemotePath,
skills: Vec<ParsedSkill>,
) {
self.remove_remote_home_skills(&host_id);
self.remote_home_directories.insert(host_id, home_dir);
self.handle_skills_added(skills);
}
pub(crate) fn remove_remote_home_skills(&mut self, host_id: &HostId) {
let Some(home_dir) = self.remote_home_directories.remove(host_id) else {
return;
};
self.remove_skills_for_directory(&home_dir);
}
fn home_directory_for_origin(
&self,
path_origin: &SkillPathOrigin,
) -> Option<LocalOrRemotePath> {
match path_origin {
SkillPathOrigin::Local => dirs::home_dir().map(LocalOrRemotePath::Local),
SkillPathOrigin::Remote { host_id } => {
self.remote_home_directories.get(host_id).cloned()
}
SkillPathOrigin::RestoredDisplayOnly | SkillPathOrigin::Unavailable => None,
}
}
fn is_home_directory(&self, path: &LocalOrRemotePath) -> bool {
match path {
LocalOrRemotePath::Local(path) => dirs::home_dir().as_ref() == Some(path),
LocalOrRemotePath::Remote(remote_path) => self
.remote_home_directories
.get(&remote_path.host_id)
.is_some_and(|home_dir| home_dir == path),
}
}
fn skill_by_location(&self, location: &LocalOrRemotePath) -> Option<&ParsedSkill> {
self.skills_by_path.get(location).or_else(|| {
location
.as_remote()
.and_then(|remote| self.bundled_skills.remote_skill_by_path(remote))
})
}
fn remove_skills_for_directory(&mut self, directory: &LocalOrRemotePath) {
let Some(skill_paths) = self.directory_skills.remove(directory) else {
return;
};
for skill_path in skill_paths {
self.remove_skill_by_path(&skill_path);
}
}
fn remove_skill_by_path(&mut self, skill_path: &LocalOrRemotePath) {
let Some(skill) = self.skills_by_path.remove(skill_path) else {
return;
};
let remove_name = self
.skills_by_name
.get_mut(&skill.name)
.is_some_and(|paths| {
paths.remove(skill_path);
paths.is_empty()
});
if remove_name {
self.skills_by_name.remove(&skill.name);
}
}
fn handle_skill_watcher_event(
&mut self,
event: SkillWatcherEvent,
ctx: &mut ModelContext<Self>,
) {
let home_skills_changed = match &event {
SkillWatcherEvent::SkillsAdded { skills } => {
skills.iter().any(|skill| skill.scope == SkillScope::Home)
}
SkillWatcherEvent::SkillsDeleted { paths } => paths.iter().any(|path| {
self.skills_by_path.values().any(|skill| {
skill.scope == SkillScope::Home
&& (skill.path.starts_with(path) || path.starts_with(&skill.path))
})
}),
};
match event {
SkillWatcherEvent::SkillsAdded { skills } => {
self.handle_skills_added(skills);
@@ -351,6 +550,9 @@ impl SkillManager {
self.handle_skills_deleted(paths);
}
}
if home_skills_changed {
ctx.emit(SkillManagerEvent::HomeSkillsChanged);
}
}
pub fn handle_skills_added(&mut self, skills: Vec<ParsedSkill>) {
@@ -375,38 +577,26 @@ impl SkillManager {
}
}
fn handle_skills_deleted(&mut self, paths: Vec<PathBuf>) {
fn handle_skills_deleted(&mut self, paths: Vec<LocalOrRemotePath>) {
for path in paths {
self.handle_path_deleted(&path);
}
}
fn handle_path_deleted(&mut self, path: &Path) {
fn handle_path_deleted(&mut self, path: &LocalOrRemotePath) {
// 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.remove_skill_by_path(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.remove_skill_by_path(skill_path);
self.directory_skills
.entry(dir.clone())
.or_default()
@@ -417,51 +607,6 @@ impl SkillManager {
}
}
/// 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) {
@@ -470,134 +615,55 @@ impl SkillManager {
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);
/// Adds a bundled skill to the skill manager for testing purposes.
#[cfg(test)]
pub fn add_bundled_skill_for_testing(
&mut self,
id: impl Into<String>,
skill: ParsedSkill,
activation: BundledSkillActivation,
) {
self.bundled_skills
.insert_local_for_testing(id, skill, activation);
}
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,
#[cfg(test)]
pub fn add_remote_bundled_skill_for_testing(
&mut self,
host_id: HostId,
id: impl Into<String>,
skill: ParsedSkill,
activation: BundledSkillActivation,
) {
self.bundled_skills
.insert_remote_for_testing(host_id, id, skill, activation);
}
}
/// 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 = ();
type Event = SkillManagerEvent;
}
impl SingletonEntity for SkillManager {}
fn path_matches_reference_location(path: &LocalOrRemotePath, reference: &SkillReference) -> bool {
match (path, reference) {
(
LocalOrRemotePath::Remote(path),
SkillReference::Path(LocalOrRemotePath::Remote(reference)),
) => path.host_id == reference.host_id,
(
LocalOrRemotePath::Local(_),
SkillReference::Path(LocalOrRemotePath::Local(_)) | SkillReference::BundledSkillId(_),
) => true,
(LocalOrRemotePath::Local(_), SkillReference::Path(LocalOrRemotePath::Remote(_)))
| (
LocalOrRemotePath::Remote(_),
SkillReference::Path(LocalOrRemotePath::Local(_)) | SkillReference::BundledSkillId(_),
) => false,
}
}
#[cfg(test)]
#[path = "skill_manager_tests.rs"]
mod tests;
File diff suppressed because it is too large Load Diff
+80 -70
View File
@@ -1,27 +1,26 @@
//! Utility functions for working with skills.
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use ai::skills::{
provider_parent_directory_for_skills_root, provider_rank, ParsedSkill, SkillPathOrigin,
SkillProvider,
};
use lazy_static::lazy_static;
use siphasher::sip::SipHasher;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::Icon;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use warpui::prelude::MouseStateHandle;
use warpui::{AppContext, Element, EventContext, SingletonEntity};
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 galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::Icon;
use galaxyui::prelude::MouseStateHandle;
use galaxyui::EventContext;
use galaxyui::{AppContext, Element, SingletonEntity};
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 crate::galaxy_managed_paths_watcher::galaxy_managed_skill_dirs;
lazy_static! {
static ref CONTENT_HASHER: SipHasher = SipHasher::new_with_keys(0, 0);
@@ -30,10 +29,11 @@ lazy_static! {
/// 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`].
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
fn try_insert_skill(
dedup_map: &mut HashMap<u64, SkillDescriptor>,
descriptor: SkillDescriptor,
dir_path: &Path,
dir_path: &LocalOrRemotePath,
content: &str,
) {
let mut hasher = *CONTENT_HASHER;
@@ -54,8 +54,44 @@ fn try_insert_skill(
}
}
/// Deduplicates skills with identical content installed under the same directory across
/// multiple providers, keeping the single best representative per
/// Accumulates file-backed skills from one or more catalogs and keeps the best
/// representative for each owning-directory-and-content pair.
#[derive(Default)]
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
pub(crate) struct SkillDeduplicator {
dedup_map: HashMap<u64, SkillDescriptor>,
}
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
impl SkillDeduplicator {
pub(crate) fn insert(&mut self, dir_path: &LocalOrRemotePath, skill: &ParsedSkill) {
try_insert_skill(
&mut self.dedup_map,
SkillDescriptor::from(skill.clone()),
dir_path,
&skill.content,
);
}
pub(crate) fn extend_paths(
&mut self,
skill_paths: &[(LocalOrRemotePath, LocalOrRemotePath)],
skills_by_path: &HashMap<LocalOrRemotePath, ParsedSkill>,
) {
for (dir_path, path) in skill_paths {
if let Some(skill) = skills_by_path.get(path) {
self.insert(dir_path, skill);
}
}
}
pub(crate) fn into_descriptors(self) -> Vec<SkillDescriptor> {
self.dedup_map.into_values().collect()
}
}
/// Deduplicates paths from one indexed catalog when identical content is 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
@@ -64,37 +100,29 @@ fn try_insert_skill(
///
/// 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))]
#[cfg(test)]
pub(crate) fn unique_skills(
skill_paths: &[(PathBuf, PathBuf)],
skills_by_path: &HashMap<PathBuf, ParsedSkill>,
skill_paths: &[(LocalOrRemotePath, LocalOrRemotePath)],
skills_by_path: &HashMap<LocalOrRemotePath, 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()
let mut deduplicator = SkillDeduplicator::default();
deduplicator.extend_paths(skill_paths, skills_by_path);
deduplicator.into_descriptors()
}
/// 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>,
working_directory: Option<&LocalOrRemotePath>,
path_origin: &SkillPathOrigin,
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 current_skills = SkillManager::as_ref(app).get_skills_for_working_directory_with_origin(
working_directory,
path_origin,
app,
);
let previous_skills: Option<Vec<SkillDescriptor>> =
conversation_id.and_then(|conversation_id| {
@@ -164,35 +192,17 @@ pub fn icon_override_for_skill_name(name: &str) -> Option<Icon> {
}
}
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 {
galaxy_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"));
}
pub fn skill_path_from_location(location: &LocalOrRemotePath) -> Option<LocalOrRemotePath> {
let mut current = Some(location.clone());
while let Some(candidate_skill_dir) = current {
if candidate_skill_dir
.parent()
.and_then(|provider_dir| provider_parent_directory_for_skills_root(&provider_dir))
.is_some()
{
return Some(candidate_skill_dir.join("SKILL.md"));
}
current = candidate_skill_dir.parent();
}
None
}
+66 -62
View File
@@ -1,59 +1,39 @@
use super::*;
use ai::skills::{ParsedSkill, SkillProvider, SkillScope};
use std::path::PathBuf;
use ai::skills::{ParsedSkill, SkillProvider, SkillScope};
use warp_util::host_id::HostId;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use warp_util::remote_path::RemotePath;
use warp_util::standardized_path::StandardizedPath;
use super::*;
fn remote_location(path: &str) -> LocalOrRemotePath {
LocalOrRemotePath::Remote(RemotePath::new(
HostId::new("remote-host".to_string()),
StandardizedPath::try_new(path).unwrap(),
))
}
#[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);
fn skill_path_from_unix_encoded_remote_location() {
let location = remote_location("/repo/.agents/skills/deploy/scripts/run.sh");
assert_eq!(
result,
Some(PathBuf::from("/home/user/.claude/skills/my-skill/SKILL.md"))
skill_path_from_location(&location),
Some(remote_location("/repo/.agents/skills/deploy/SKILL.md"))
);
}
#[test]
fn test_skill_path_from_file_path_warp_home_skill() {
let Some(galaxy_home_skills_dir) = galaxy_core::paths::galaxy_home_skills_dir() else {
eprintln!("Skipping test: Warp home skills directory not available");
return;
};
let warp_home_skill = galaxy_home_skills_dir
.join("my-skill")
.join("assets")
.join("image.png");
let result = skill_path_from_file_path(&warp_home_skill);
fn skill_path_from_windows_encoded_remote_location() {
let location = remote_location(r"C:\repo\.agents\skills\deploy\scripts\run.ps1");
assert_eq!(
result,
Some(galaxy_home_skills_dir.join("my-skill").join("SKILL.md"))
skill_path_from_location(&location),
Some(remote_location(r"C:\repo\.agents\skills\deploy\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");
@@ -62,7 +42,7 @@ fn test_unique_skills_dedupes_identical_skills_same_dir() {
let content = "---\nname: test-skill\ndescription: A test skill\n---\nContent here";
let skill = ParsedSkill {
path: skill_path1.clone(),
path: LocalOrRemotePath::Local(skill_path1.clone()),
name: "test-skill".to_string(),
description: "A test skill".to_string(),
content: content.to_string(),
@@ -72,7 +52,7 @@ fn test_unique_skills_dedupes_identical_skills_same_dir() {
};
let skill2 = ParsedSkill {
path: skill_path2.clone(),
path: LocalOrRemotePath::Local(skill_path2.clone()),
name: "test-skill".to_string(),
description: "A test skill".to_string(),
content: content.to_string(),
@@ -82,12 +62,18 @@ fn test_unique_skills_dedupes_identical_skills_same_dir() {
};
let mut skills_by_path = HashMap::new();
skills_by_path.insert(skill_path1.clone(), skill);
skills_by_path.insert(skill_path2.clone(), skill2);
skills_by_path.insert(LocalOrRemotePath::Local(skill_path1.clone()), skill);
skills_by_path.insert(LocalOrRemotePath::Local(skill_path2.clone()), skill2);
let skill_paths = vec![
(shared_skill_dir.clone(), skill_path1),
(shared_skill_dir, skill_path2),
(
LocalOrRemotePath::Local(shared_skill_dir.clone()),
LocalOrRemotePath::Local(skill_path1),
),
(
LocalOrRemotePath::Local(shared_skill_dir),
LocalOrRemotePath::Local(skill_path2),
),
];
let result = unique_skills(&skill_paths, &skills_by_path);
@@ -105,7 +91,7 @@ fn test_unique_skills_does_not_dedupe_different_dirs() {
let content = "---\nname: test-skill\ndescription: A test skill\n---\nContent here";
let home_skill = ParsedSkill {
path: home_path.clone(),
path: LocalOrRemotePath::Local(home_path.clone()),
name: "test-skill".to_string(),
description: "A test skill".to_string(),
content: content.to_string(),
@@ -115,7 +101,7 @@ fn test_unique_skills_does_not_dedupe_different_dirs() {
};
let project_skill = ParsedSkill {
path: project_path.clone(),
path: LocalOrRemotePath::Local(project_path.clone()),
name: "test-skill".to_string(),
description: "A test skill".to_string(),
content: content.to_string(),
@@ -125,10 +111,22 @@ fn test_unique_skills_does_not_dedupe_different_dirs() {
};
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);
skills_by_path.insert(LocalOrRemotePath::Local(home_path.clone()), home_skill);
skills_by_path.insert(
LocalOrRemotePath::Local(project_path.clone()),
project_skill,
);
let skill_paths = vec![(home_dir, home_path), (project_dir, project_path)];
let skill_paths = vec![
(
LocalOrRemotePath::Local(home_dir),
LocalOrRemotePath::Local(home_path),
),
(
LocalOrRemotePath::Local(project_dir),
LocalOrRemotePath::Local(project_path),
),
];
let result = unique_skills(&skill_paths, &skills_by_path);
assert_eq!(
@@ -148,7 +146,7 @@ fn test_unique_skills_does_not_dedupe_different_content() {
let content2 = "---\nname: test-skill\ndescription: A test skill\n---\nDifferent content";
let skill1 = ParsedSkill {
path: skill_path1.clone(),
path: LocalOrRemotePath::Local(skill_path1.clone()),
name: "test-skill".to_string(),
description: "A test skill".to_string(),
content: content1.to_string(),
@@ -158,7 +156,7 @@ fn test_unique_skills_does_not_dedupe_different_content() {
};
let skill2 = ParsedSkill {
path: skill_path2.clone(),
path: LocalOrRemotePath::Local(skill_path2.clone()),
name: "test-skill".to_string(),
description: "A test skill".to_string(),
content: content2.to_string(),
@@ -168,12 +166,18 @@ fn test_unique_skills_does_not_dedupe_different_content() {
};
let mut skills_by_path = HashMap::new();
skills_by_path.insert(skill_path1.clone(), skill1);
skills_by_path.insert(skill_path2.clone(), skill2);
skills_by_path.insert(LocalOrRemotePath::Local(skill_path1.clone()), skill1);
skills_by_path.insert(LocalOrRemotePath::Local(skill_path2.clone()), skill2);
let skill_paths = vec![
(shared_skill_dir.clone(), skill_path1),
(shared_skill_dir, skill_path2),
(
LocalOrRemotePath::Local(shared_skill_dir.clone()),
LocalOrRemotePath::Local(skill_path1),
),
(
LocalOrRemotePath::Local(shared_skill_dir),
LocalOrRemotePath::Local(skill_path2),
),
];
let result = unique_skills(&skill_paths, &skills_by_path);