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