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

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+136 -9
View File
@@ -1,12 +1,14 @@
use std::path::PathBuf;
use crate::{
agent::action_result::{AnyFileContent, FileContext},
skills::{ParsedSkill, SkillProvider, SkillScope},
};
use warp_multi_agent_api as api;
use thiserror::Error;
use warp_multi_agent_api as api;
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 crate::agent::action_result::{AnyFileContent, FileContext};
use crate::skills::{ParsedSkill, SkillProvider, SkillReference, SkillScope};
#[derive(Error, Debug)]
pub enum SkillConversionError {
@@ -22,14 +24,126 @@ pub enum SkillConversionError {
ProviderInvalid,
#[error("Invalid content")]
ContentInvalid,
#[error("Skill path origin is unavailable")]
PathOriginUnavailable,
#[error("Invalid remote skill path")]
RemotePathInvalid,
}
/// Identifies how a string skill path from an API payload should be interpreted.
///
/// Live agent responses can be decoded from the active session's location. Restored payloads do
/// not carry enough session identity to safely reconstruct path-based skill locations, so callers
/// must use [`SkillPathOrigin::Unavailable`] rather than silently assuming the local filesystem.
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum SkillPathOrigin {
Local,
Remote {
host_id: HostId,
},
/// Path identity could not be restored, but the API payload already carries the skill
/// descriptor and content needed to render a historical transcript.
///
/// This intentionally uses a local path wrapper only as a display-compatible identity for
/// restored conversation UI. Live execution paths should use [`SkillPathOrigin::Local`] or
/// [`SkillPathOrigin::Remote`] so local/remote provenance is preserved.
RestoredDisplayOnly,
Unavailable,
}
impl SkillPathOrigin {
pub fn location_for_path(
&self,
path: impl Into<String>,
) -> Result<LocalOrRemotePath, SkillConversionError> {
let path = path.into();
match self {
SkillPathOrigin::Local | SkillPathOrigin::RestoredDisplayOnly => {
// Normalize the path to collapse duplicate separators (e.g. `//workspace/...`
// → `/workspace/...`) so skill cache lookups match the filesystem-derived keys.
// We operate on the raw string rather than using `PathBuf::components().collect()`
// because the latter re-serialises with platform-specific separators (backslashes
// on Windows) and treats leading `//` as a UNC prefix on Windows.
let normalized = collapse_slashes(&path);
Ok(LocalOrRemotePath::Local(PathBuf::from(normalized)))
}
SkillPathOrigin::Remote { host_id } => {
let path = StandardizedPath::try_new(&path)
.map_err(|_| SkillConversionError::RemotePathInvalid)?;
Ok(LocalOrRemotePath::Remote(RemotePath::new(
host_id.clone(),
path,
)))
}
SkillPathOrigin::Unavailable => Err(SkillConversionError::PathOriginUnavailable),
}
}
}
/// Collapse consecutive `/` separators into a single one.
///
/// Skill paths are always forward-slash POSIX-style paths on all platforms, so we normalise
/// at the string level rather than using [`std::path::PathBuf::components`], which would
/// re-serialise with backslashes on Windows and misinterpret `//prefix` as a UNC path.
fn collapse_slashes(path: &str) -> String {
let mut result = String::with_capacity(path.len());
let mut prev_slash = false;
for ch in path.chars() {
if ch == '/' {
if !prev_slash {
result.push(ch);
}
prev_slash = true;
} else {
result.push(ch);
prev_slash = false;
}
}
result
}
fn skill_reference_for_path(
path: impl Into<String>,
path_origin: &SkillPathOrigin,
) -> Result<SkillReference, SkillConversionError> {
path_origin
.location_for_path(path)
.map(SkillReference::Path)
}
pub fn skill_reference_from_api_skill_ref(
skill_ref: api::SkillRef,
path_origin: &SkillPathOrigin,
) -> Option<SkillReference> {
match skill_ref.skill_reference {
Some(api::skill_ref::SkillReference::Path(path)) => {
skill_reference_for_path(path, path_origin).ok()
}
Some(api::skill_ref::SkillReference::BundledSkillId(id)) => {
Some(SkillReference::BundledSkillId(id))
}
None => None,
}
}
pub fn skill_reference_from_read_skill_ref(
skill_reference: api::message::tool_call::read_skill::SkillReference,
path_origin: &SkillPathOrigin,
) -> Result<SkillReference, SkillConversionError> {
match skill_reference {
api::message::tool_call::read_skill::SkillReference::SkillPath(path) => {
skill_reference_for_path(path, path_origin)
}
api::message::tool_call::read_skill::SkillReference::BundledSkillId(id) => {
Ok(SkillReference::BundledSkillId(id))
}
}
}
impl From<ParsedSkill> for api::Skill {
fn from(skill: ParsedSkill) -> Self {
api::Skill {
descriptor: Some(api::SkillDescriptor {
skill_reference: Some(api::skill_descriptor::SkillReference::Path(
skill.path.to_string_lossy().to_string(),
skill.path.display_path(),
)),
name: skill.name,
description: skill.description,
@@ -37,7 +151,7 @@ impl From<ParsedSkill> for api::Skill {
provider: Some(skill.provider.into()),
}),
content: Some(api::FileContent {
file_path: skill.path.to_string_lossy().to_string(),
file_path: skill.path.display_path(),
content: skill.content,
line_range: skill
.line_range
@@ -89,6 +203,15 @@ impl TryFrom<api::Skill> for ParsedSkill {
type Error = SkillConversionError;
fn try_from(api_skill: api::Skill) -> Result<Self, Self::Error> {
Self::try_from_api_with_origin(api_skill, &SkillPathOrigin::Unavailable)
}
}
impl ParsedSkill {
pub fn try_from_api_with_origin(
api_skill: api::Skill,
path_origin: &SkillPathOrigin,
) -> Result<Self, SkillConversionError> {
let Some(descriptor) = api_skill.descriptor else {
return Err(SkillConversionError::MissingDescriptor);
};
@@ -121,7 +244,7 @@ impl TryFrom<api::Skill> for ParsedSkill {
let line_range = context.line_range.as_ref();
Ok(ParsedSkill {
path: PathBuf::from(&path),
path: path_origin.location_for_path(path)?,
name: descriptor.name,
description: descriptor.description,
content,
@@ -164,3 +287,7 @@ fn convert_provider(
api::skill_descriptor::provider::Type::OpenCode(_) => Ok(SkillProvider::OpenCode),
}
}
#[cfg(test)]
#[path = "conversion_tests.rs"]
mod conversion_tests;
+242
View File
@@ -0,0 +1,242 @@
use warp_multi_agent_api as api;
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::{
skill_reference_from_api_skill_ref, skill_reference_from_read_skill_ref, SkillConversionError,
SkillPathOrigin,
};
use crate::skills::{ParsedSkill, SkillProvider, SkillReference, SkillScope};
fn api_project_skill(path: &str) -> api::Skill {
api::Skill {
descriptor: Some(api::SkillDescriptor {
skill_reference: Some(api::skill_descriptor::SkillReference::Path(
path.to_string(),
)),
name: "deploy".to_string(),
description: "Deploy the service".to_string(),
scope: Some(api::skill_descriptor::Scope {
r#type: Some(api::skill_descriptor::scope::Type::Project(())),
}),
provider: Some(api::skill_descriptor::Provider {
r#type: Some(api::skill_descriptor::provider::Type::Agents(())),
}),
}),
content: Some(api::FileContent {
file_path: path.to_string(),
content: "# Deploy".to_string(),
line_range: None,
}),
}
}
#[test]
fn try_from_api_with_remote_origin_preserves_host_identity() {
let host_id = HostId::new("remote-host".to_string());
let parsed = ParsedSkill::try_from_api_with_origin(
api_project_skill("/repo/.agents/skills/deploy/SKILL.md"),
&SkillPathOrigin::Remote {
host_id: host_id.clone(),
},
)
.expect("remote project skill should convert");
let LocalOrRemotePath::Remote(path) = parsed.path else {
panic!("expected a remote skill path");
};
assert_eq!(path.host_id, host_id);
assert_eq!(path.path.as_str(), "/repo/.agents/skills/deploy/SKILL.md");
}
#[test]
fn skill_ref_with_remote_origin_preserves_host_identity() {
let host_id = HostId::new("remote-host".to_string());
let skill_reference = skill_reference_from_api_skill_ref(
api::SkillRef {
skill_reference: Some(api::skill_ref::SkillReference::Path(
"/repo/.agents/skills/deploy/SKILL.md".to_string(),
)),
},
&SkillPathOrigin::Remote {
host_id: host_id.clone(),
},
);
let Some(SkillReference::Path(LocalOrRemotePath::Remote(path))) = skill_reference else {
panic!("expected a remote skill path");
};
assert_eq!(path.host_id, host_id);
assert_eq!(path.path.as_str(), "/repo/.agents/skills/deploy/SKILL.md");
}
#[test]
fn parsed_skill_api_conversion_emits_plain_path_reference() {
let skill_path = LocalOrRemotePath::Remote(RemotePath::new(
HostId::new("remote-host".to_string()),
StandardizedPath::try_new("/repo/.agents/skills/deploy/SKILL.md").unwrap(),
));
let api_skill: api::Skill = ParsedSkill {
path: skill_path.clone(),
name: "deploy".to_string(),
description: "Deploy the service".to_string(),
content: "# Deploy".to_string(),
line_range: None,
scope: SkillScope::Project,
provider: SkillProvider::Agents,
}
.into();
let descriptor = api_skill
.descriptor
.expect("converted skill should have descriptor");
assert_eq!(
descriptor.skill_reference,
Some(api::skill_descriptor::SkillReference::Path(
skill_path.display_path()
))
);
}
#[test]
fn skill_reference_api_conversion_emits_plain_path_reference() {
let skill_path = LocalOrRemotePath::Remote(RemotePath::new(
HostId::new("remote-host".to_string()),
StandardizedPath::try_new("/repo/.agents/skills/deploy/SKILL.md").unwrap(),
));
let reference: api::skill_descriptor::SkillReference =
SkillReference::Path(skill_path.clone()).into();
assert_eq!(
reference,
api::skill_descriptor::SkillReference::Path(skill_path.display_path())
);
}
#[test]
fn try_from_api_with_unavailable_origin_rejects_path_based_skills() {
let error = ParsedSkill::try_from_api_with_origin(
api_project_skill("/repo/.agents/skills/deploy/SKILL.md"),
&SkillPathOrigin::Unavailable,
)
.expect_err("restored skills without host context should not fabricate local paths");
assert!(matches!(error, SkillConversionError::PathOriginUnavailable));
}
#[test]
fn skill_ref_with_unavailable_origin_preserves_bundled_skills() {
let skill_reference = skill_reference_from_api_skill_ref(
api::SkillRef {
skill_reference: Some(api::skill_ref::SkillReference::BundledSkillId(
"review-comments".to_string(),
)),
},
&SkillPathOrigin::Unavailable,
);
assert_eq!(
skill_reference,
Some(SkillReference::BundledSkillId(
"review-comments".to_string()
))
);
}
#[test]
fn local_origin_normalizes_double_leading_slash() {
let result = SkillPathOrigin::Local
.location_for_path("//workspace/common-skills/.agents/skills/deploy/SKILL.md")
.expect("double-slash path should be accepted");
let LocalOrRemotePath::Local(path) = result else {
panic!("expected a local path");
};
assert_eq!(
path.to_str().unwrap(),
"/workspace/common-skills/.agents/skills/deploy/SKILL.md"
);
}
#[test]
fn local_origin_normalizes_multiple_slashes() {
let result = SkillPathOrigin::Local
.location_for_path("///workspace///skills///SKILL.md")
.expect("multi-slash path should be accepted");
let LocalOrRemotePath::Local(path) = result else {
panic!("expected a local path");
};
assert_eq!(path.to_str().unwrap(), "/workspace/skills/SKILL.md");
}
#[test]
fn local_origin_preserves_normal_absolute_path() {
let result = SkillPathOrigin::Local
.location_for_path("/workspace/.agents/skills/deploy/SKILL.md")
.expect("normal path should be accepted");
let LocalOrRemotePath::Local(path) = result else {
panic!("expected a local path");
};
assert_eq!(
path.to_str().unwrap(),
"/workspace/.agents/skills/deploy/SKILL.md"
);
}
#[test]
fn restored_display_origin_normalizes_double_leading_slash() {
let result = SkillPathOrigin::RestoredDisplayOnly
.location_for_path("//repo/.agents/skills/deploy/SKILL.md")
.expect("double-slash path should be accepted");
let LocalOrRemotePath::Local(path) = result else {
panic!("expected a local path");
};
assert_eq!(
path.to_str().unwrap(),
"/repo/.agents/skills/deploy/SKILL.md"
);
}
#[test]
fn read_skill_ref_with_local_origin_normalizes_double_slash() {
let skill_reference = skill_reference_from_read_skill_ref(
api::message::tool_call::read_skill::SkillReference::SkillPath(
"//workspace/.agents/skills/deploy/SKILL.md".to_string(),
),
&SkillPathOrigin::Local,
)
.expect("double-slash read_skill path should convert");
let SkillReference::Path(LocalOrRemotePath::Local(path)) = skill_reference else {
panic!("expected a local skill path");
};
assert_eq!(
path.to_str().unwrap(),
"/workspace/.agents/skills/deploy/SKILL.md"
);
}
#[test]
fn read_skill_ref_with_remote_origin_preserves_host_identity() {
let host_id = HostId::new("remote-host".to_string());
let skill_reference = skill_reference_from_read_skill_ref(
api::message::tool_call::read_skill::SkillReference::SkillPath(
"/repo/.agents/skills/deploy/SKILL.md".to_string(),
),
&SkillPathOrigin::Remote {
host_id: host_id.clone(),
},
)
.expect("remote read_skill skill references should convert");
let SkillReference::Path(LocalOrRemotePath::Remote(path)) = skill_reference else {
panic!("expected a remote skill path");
};
assert_eq!(path.host_id, host_id);
assert_eq!(path.path.as_str(), "/repo/.agents/skills/deploy/SKILL.md");
}
+9 -4
View File
@@ -4,11 +4,16 @@ mod parser;
mod read_skills;
mod skill_provider;
mod skill_reference;
pub use parse_skill::{parse_bundled_skill, parse_skill, ParsedSkill};
pub use conversion::{
skill_reference_from_api_skill_ref, skill_reference_from_read_skill_ref, SkillConversionError,
SkillPathOrigin,
};
pub use parse_skill::{
parse_bundled_skill, parse_skill, parse_skill_content_at_location, ParsedSkill,
};
pub use read_skills::read_skills;
pub use skill_provider::{
get_provider_for_path, home_skills_path, provider_rank, SkillProvider, SkillProviderDefinition,
SkillScope, SKILL_PROVIDER_DEFINITIONS,
get_provider_for_path, home_skills_path, provider_parent_directory_for_skills_root,
provider_rank, SkillProvider, SkillProviderDefinition, SkillScope, SKILL_PROVIDER_DEFINITIONS,
};
pub use skill_reference::SkillReference;
+68 -49
View File
@@ -1,13 +1,16 @@
use std::fmt::Display;
use std::fs;
use std::ops::Range;
use std::path::Path;
use anyhow::Result;
use lazy_static::lazy_static;
use regex::Regex;
use std::fmt::Display;
use std::ops::Range;
use std::path::{Path, PathBuf};
use super::parser::parse_markdown_file;
use super::skill_provider::{get_provider_for_path, get_scope_for_path, SkillProvider, SkillScope};
use thiserror::Error;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use super::parser::parse_markdown_content;
use super::skill_provider::{get_provider_for_path, get_scope_for_path, SkillProvider, SkillScope};
const MAX_SKILL_DESCRIPTION_CHARS: usize = 512;
@@ -17,6 +20,50 @@ lazy_static! {
static ref INCOMPLETE_SENTENCE: Regex =
Regex::new(r"[^.!?]*$").expect("Incomplete sentence regex should be valid");
}
/// Parse skill markdown content that was fetched outside the local filesystem.
///
/// This is used for remote project skills, whose SKILL.md body arrives through
/// the remote file-read transport rather than `std::fs`.
pub fn parse_skill_content_at_location(
path: LocalOrRemotePath,
content: &str,
provider: SkillProvider,
scope: SkillScope,
) -> Result<ParsedSkill> {
let parsed = parse_markdown_content(content)?;
let name = match parsed
.front_matter
.get("name")
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
Some(name) => name.to_string(),
None => derive_skill_name_from_path(&path)?,
};
let description = match parsed
.front_matter
.get("description")
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
Some(description) => description.to_string(),
None => truncate_skill_description(
&derive_description_from_content(&parsed.content, parsed.line_range.as_ref())
.unwrap_or_default(),
),
};
Ok(ParsedSkill {
path,
name,
description,
content: parsed.content,
line_range: parsed.line_range,
provider,
scope,
})
}
#[derive(Error, Debug)]
pub enum ParseSkillError {
@@ -29,7 +76,7 @@ pub enum ParseSkillError {
/// Represents a parsed skill with validated fields
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedSkill {
pub path: PathBuf,
pub path: LocalOrRemotePath,
pub name: String,
pub description: String,
/// The entire content of the file (including front matter)
@@ -52,7 +99,7 @@ impl ParsedSkill {
impl Display for ParsedSkill {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Skill: {}", self.path.display())
write!(f, "Skill: {}", self.path.display_path())
}
}
@@ -64,9 +111,10 @@ impl Display for ParsedSkill {
/// # Returns
/// * `Result<ParsedSkill>` - Parsed skill with validated name and description
pub fn parse_skill(path: &Path) -> Result<ParsedSkill> {
let provider = get_provider_for_path(path).unwrap_or(SkillProvider::Agents);
let provider_path = LocalOrRemotePath::Local(path.to_path_buf());
let provider = get_provider_for_path(&provider_path).unwrap_or(SkillProvider::Agents);
let scope = get_scope_for_path(path);
parse_skill_internal(path, provider, scope)
parse_local_skill_internal(path, provider, scope)
}
/// Parse a bundled skill markdown file.
@@ -81,55 +129,26 @@ pub fn parse_skill(path: &Path) -> Result<ParsedSkill> {
/// # Returns
/// * `Result<ParsedSkill>` - Parsed skill with validated name and description
pub fn parse_bundled_skill(path: &Path) -> Result<ParsedSkill> {
parse_skill_internal(path, SkillProvider::Warp, SkillScope::Bundled)
parse_local_skill_internal(path, SkillProvider::Warp, SkillScope::Bundled)
}
fn parse_skill_internal(
fn parse_local_skill_internal(
path: &Path,
provider: SkillProvider,
scope: SkillScope,
) -> Result<ParsedSkill> {
let parsed = parse_markdown_file(path)?;
let name = match parsed
.front_matter
.get("name")
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
Some(name) => name.to_string(),
None => derive_skill_name_from_path(path)?,
};
let description = match parsed
.front_matter
.get("description")
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
Some(description) => description.to_string(),
None => truncate_skill_description(
&derive_description_from_content(&parsed.content, parsed.line_range.as_ref())
.unwrap_or_default(),
),
};
Ok(ParsedSkill {
path: path.to_path_buf(),
name,
description,
content: parsed.content,
line_range: parsed.line_range,
let content = fs::read_to_string(path)?;
parse_skill_content_at_location(
LocalOrRemotePath::Local(path.to_path_buf()),
&content,
provider,
scope,
})
)
}
fn derive_skill_name_from_path(path: &Path) -> Result<String> {
fn derive_skill_name_from_path(path: &LocalOrRemotePath) -> Result<String> {
path.parent()
.and_then(|parent| parent.file_name())
.and_then(|name| name.to_str())
.map(|name| name.to_string())
.and_then(|parent| parent.file_name().map(str::to_owned))
.ok_or(ParseSkillError::CouldNotDeriveSkillNameFromPath.into())
}
@@ -202,5 +221,5 @@ fn truncate_skill_description(description: &str) -> String {
}
#[cfg(test)]
#[path = "parse_skill_test.rs"]
#[path = "parse_skill_tests.rs"]
mod parse_skill_test;
@@ -1,5 +1,7 @@
use std::path::PathBuf;
use tempfile::TempDir;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use super::*;
@@ -48,7 +50,7 @@ Show concrete examples of using this Skill.
// Total of 12 lines, so line_range is 5..13
assert_eq!(result.line_range, Some(5..13));
// Verify path is the full file path
assert_eq!(result.path, skill_file);
assert_eq!(result.path, LocalOrRemotePath::Local(skill_file));
}
#[test]
+4 -18
View File
@@ -1,10 +1,9 @@
use std::collections::HashMap;
use std::ops::Range;
use anyhow::{Context, Result};
use regex::Regex;
use serde_yaml::Value;
use std::collections::HashMap;
use std::fs;
use std::ops::Range;
use std::path::Path;
/// Represents a parsed markdown file with YAML front matter
#[derive(Debug)]
@@ -20,19 +19,6 @@ pub struct ParsedMarkdown {
pub line_range: Option<Range<usize>>,
}
/// Parse a markdown file with YAML front matter
///
/// # Arguments
/// * `path` - Path to the markdown file to parse
///
/// # Returns
/// * `Result<ParsedMarkdown>` - Parsed document with front matter and content
#[allow(dead_code)]
pub fn parse_markdown_file(path: &Path) -> Result<ParsedMarkdown> {
let content = fs::read_to_string(path)?;
parse_markdown_content(&content)
}
/// Parse markdown content with YAML front matter
#[allow(dead_code)]
pub(crate) fn parse_markdown_content(content: &str) -> Result<ParsedMarkdown> {
@@ -97,5 +83,5 @@ pub(crate) fn parse_markdown_content(content: &str) -> Result<ParsedMarkdown> {
}
#[cfg(test)]
#[path = "parser_test.rs"]
#[path = "parser_tests.rs"]
mod parser_test;
+1 -1
View File
@@ -46,5 +46,5 @@ pub fn read_skills(path: &Path) -> Vec<ParsedSkill> {
}
#[cfg(test)]
#[path = "read_skills_test.rs"]
#[path = "read_skills_tests.rs"]
mod read_skills_test;
@@ -1,6 +1,9 @@
use super::*;
use std::fs;
use tempfile::tempdir;
use warp_util::local_or_remote_path::LocalOrRemotePath;
use super::*;
#[test]
fn test_read_skills_with_valid_skills() {
@@ -49,7 +52,7 @@ This is the second test skill.
let skill1 = skills.iter().find(|s| s.name == "test-skill-1").unwrap();
assert_eq!(
skill1.path,
skill1_dir.join("SKILL.md").to_string_lossy().to_string()
LocalOrRemotePath::Local(skill1_dir.join("SKILL.md"))
);
assert_eq!(skill1.description, "First test skill");
assert!(skill1.content.contains("# Test Skill 1"));
@@ -60,7 +63,7 @@ This is the second test skill.
let skill2 = skills.iter().find(|s| s.name == "test-skill-2").unwrap();
assert_eq!(
skill2.path,
skill2_dir.join("SKILL.md").to_string_lossy().to_string()
LocalOrRemotePath::Local(skill2_dir.join("SKILL.md"))
);
assert_eq!(skill2.description, "Second test skill");
assert!(skill2.content.contains("# Test Skill 2"));
+60 -47
View File
@@ -3,16 +3,16 @@
//! This module defines the supported skill providers (i.e. Agents, Claude, Codex, Warp) and their
//! associated skills directory paths. It provides utilities for looking up providers
//! from paths and vice versa.
use dirs::home_dir;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use dirs::home_dir;
use serde::{Deserialize, Serialize};
use strum_macros::{Display, EnumString, VariantNames};
use galaxy_core::ui::color::CLAUDE_ORANGE;
use galaxy_core::ui::icons::Icon;
use galaxy_core::ui::theme::Fill;
use strum_macros::{Display, EnumString, VariantNames};
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
/// Represents a skill provider/origin (Agents, Claude, Codex, or Warp).
#[derive(
@@ -167,29 +167,65 @@ pub fn home_skills_path(provider: SkillProvider) -> Option<PathBuf> {
home_dir().map(|home_dir| home_dir.join(&definition.skills_path))
}
/// Returns the skill provider for a given path, if it matches a known skill provider directory.
/// For example:
/// get_provider_for_path(Path::new("/repo/.claude/skills/my-skill/SKILL.md")) returns Some(SkillProvider::Claude).
/// Handles both SKILL.md files and files nested within a skill directory.
pub fn get_provider_for_path(path: &Path) -> Option<SkillProvider> {
let path_components: Vec<_> = path.components().collect();
/// Returns the skill provider for a location, if it matches a known skill provider directory.
///
/// Local locations retain home-directory-aware matching. All other locations are
/// classified by provider-directory structure using their standardized path representation.
pub fn get_provider_for_path(path: &LocalOrRemotePath) -> Option<SkillProvider> {
path.to_local_path()
.and_then(get_home_provider_for_local_path)
.or_else(|| get_provider_for_structural_path(path))
}
for def in SKILL_PROVIDER_DEFINITIONS.iter() {
if home_skills_path(def.provider)
.into_iter()
.any(|home_skills_path| path.starts_with(home_skills_path))
{
return Some(def.provider);
fn get_home_provider_for_local_path(path: &Path) -> Option<SkillProvider> {
SKILL_PROVIDER_DEFINITIONS
.iter()
.find(|definition| {
home_skills_path(definition.provider)
.into_iter()
.any(|home_skills_path| path.starts_with(home_skills_path))
})
.map(|definition| definition.provider)
}
/// Returns the directory containing a provider's skills root when `skills_root` has a known
/// provider directory suffix, preserving the original local or remote location encoding.
///
/// For example, `/repo/.agents/skills` resolves to `/repo`, regardless of whether the location
/// is encoded with Unix or Windows path separators.
pub fn provider_parent_directory_for_skills_root(
skills_root: &LocalOrRemotePath,
) -> Option<LocalOrRemotePath> {
match_provider_skills_root(skills_root).map(|(_, parent_directory)| parent_directory)
}
fn get_provider_for_structural_path(path: &LocalOrRemotePath) -> Option<SkillProvider> {
let mut current = Some(path.clone());
while let Some(candidate) = current {
if let Some((provider, _)) = match_provider_skills_root(&candidate) {
return Some(provider);
}
current = candidate.parent();
}
None
}
// Retrieves path components for the skill provider directory (i.e., [".claude", "skills"])
let skill_components: Vec<_> = def.skills_path.components().collect();
// Checks if some consecutive components of the path match the skill provider directory
for window in path_components.windows(skill_components.len()) {
if window == skill_components.as_slice() {
return Some(def.provider);
fn match_provider_skills_root(
skills_root: &LocalOrRemotePath,
) -> Option<(SkillProvider, LocalOrRemotePath)> {
for definition in SKILL_PROVIDER_DEFINITIONS.iter() {
let mut parent_directory = skills_root.clone();
let mut matches_provider = true;
for component in definition.skills_path.components().rev() {
let expected_component = component.as_os_str().to_str()?;
if parent_directory.file_name() != Some(expected_component) {
matches_provider = false;
break;
}
parent_directory = parent_directory.parent()?;
}
if matches_provider {
return Some((definition.provider, parent_directory));
}
}
None
@@ -211,28 +247,5 @@ pub fn get_scope_for_path(path: &Path) -> SkillScope {
}
#[cfg(test)]
mod tests {
use super::{
get_provider_for_path, get_scope_for_path, home_skills_path, SkillProvider, SkillScope,
};
#[test]
fn warp_home_skills_path_uses_warp_home_path() {
assert_eq!(
home_skills_path(SkillProvider::Warp),
galaxy_core::paths::galaxy_home_skills_dir()
);
}
#[test]
fn warp_home_skill_path_is_home_warp_skill() {
let Some(galaxy_home_skills_dir) = galaxy_core::paths::galaxy_home_skills_dir() else {
eprintln!("Skipping test: home directory not available");
return;
};
let path = galaxy_home_skills_dir.join("my-skill").join("SKILL.md");
assert_eq!(get_provider_for_path(&path), Some(SkillProvider::Warp));
assert_eq!(get_scope_for_path(&path), SkillScope::Home);
}
}
#[path = "skill_provider_tests.rs"]
mod tests;
@@ -0,0 +1,83 @@
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::{
get_provider_for_path, get_scope_for_path, home_skills_path,
provider_parent_directory_for_skills_root, SkillProvider, SkillScope,
};
#[test]
fn warp_home_skills_path_uses_warp_home_path() {
assert_eq!(
home_skills_path(SkillProvider::Warp),
galaxy_core::paths::warp_home_skills_dir()
);
}
#[test]
fn warp_home_skill_path_is_home_warp_skill() {
let Some(warp_home_skills_dir) = galaxy_core::paths::warp_home_skills_dir() else {
eprintln!("Skipping test: home directory not available");
return;
};
let path = warp_home_skills_dir.join("my-skill").join("SKILL.md");
assert_eq!(
get_provider_for_path(&LocalOrRemotePath::Local(path.clone())),
Some(SkillProvider::Warp)
);
assert_eq!(get_scope_for_path(&path), SkillScope::Home);
}
#[test]
fn remote_provider_path_is_classified_by_structure() {
let path = LocalOrRemotePath::Remote(RemotePath::new(
HostId::new("remote-host".to_string()),
StandardizedPath::try_new("/repo/.claude/skills/my-skill/SKILL.md").unwrap(),
));
assert_eq!(get_provider_for_path(&path), Some(SkillProvider::Claude));
}
#[test]
fn local_project_provider_path_is_classified_by_structure() {
let path = LocalOrRemotePath::Local(
std::env::temp_dir()
.join("repo")
.join(".claude")
.join("skills")
.join("my-skill")
.join("SKILL.md"),
);
assert_eq!(get_provider_for_path(&path), Some(SkillProvider::Claude));
}
#[test]
fn foreign_encoded_remote_provider_path_is_classified_by_structure() {
let path = LocalOrRemotePath::Remote(RemotePath::new(
HostId::new("remote-host".to_string()),
StandardizedPath::try_new(r"C:\repo\.codex\skills\my-skill\SKILL.md").unwrap(),
));
assert_eq!(get_provider_for_path(&path), Some(SkillProvider::Codex));
}
#[test]
fn foreign_encoded_remote_skills_root_resolves_provider_parent_directory() {
let host_id = HostId::new("remote-host".to_string());
let skills_root = LocalOrRemotePath::Remote(RemotePath::new(
host_id.clone(),
StandardizedPath::try_new(r"C:\repo\.agents\skills").unwrap(),
));
assert_eq!(
provider_parent_directory_for_skills_root(&skills_root),
Some(LocalOrRemotePath::Remote(RemotePath::new(
host_id,
StandardizedPath::try_new(r"C:\repo").unwrap(),
)))
);
}
+6 -6
View File
@@ -1,11 +1,13 @@
use std::fmt;
use serde::{Deserialize, Serialize};
use std::{fmt, path::PathBuf};
use warp_util::local_or_remote_path::LocalOrRemotePath;
/// An unique reference to a skill.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub enum SkillReference {
/// A skill identified by the path to its SKILL.md file.
Path(PathBuf),
Path(LocalOrRemotePath),
/// A bundled skill distributed with Warp.
BundledSkillId(String),
}
@@ -13,7 +15,7 @@ pub enum SkillReference {
impl fmt::Display for SkillReference {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
SkillReference::Path(path) => path.display().fmt(f),
SkillReference::Path(path) => path.display_path().fmt(f),
SkillReference::BundledSkillId(id) => write!(f, "@warp-skill:{id}"),
}
}
@@ -23,9 +25,7 @@ impl From<SkillReference> for warp_multi_agent_api::skill_descriptor::SkillRefer
fn from(reference: SkillReference) -> Self {
match reference {
SkillReference::Path(path) => {
warp_multi_agent_api::skill_descriptor::SkillReference::Path(
path.to_string_lossy().to_string(),
)
warp_multi_agent_api::skill_descriptor::SkillReference::Path(path.display_path())
}
SkillReference::BundledSkillId(id) => {
warp_multi_agent_api::skill_descriptor::SkillReference::BundledSkillId(id)