first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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;
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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(¤t_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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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()),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user