Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
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;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SkillConversionError {
|
||||
#[error("No descriptor provided")]
|
||||
MissingDescriptor,
|
||||
#[error("No skill_reference provided")]
|
||||
MissingReference,
|
||||
#[error("No content provided")]
|
||||
MissingContent,
|
||||
#[error("Invalid scope")]
|
||||
ScopeInvalid,
|
||||
#[error("Invalid provider")]
|
||||
ProviderInvalid,
|
||||
#[error("Invalid content")]
|
||||
ContentInvalid,
|
||||
}
|
||||
|
||||
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(),
|
||||
)),
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
scope: Some(skill.scope.into()),
|
||||
provider: Some(skill.provider.into()),
|
||||
}),
|
||||
content: Some(api::FileContent {
|
||||
file_path: skill.path.to_string_lossy().to_string(),
|
||||
content: skill.content,
|
||||
line_range: skill
|
||||
.line_range
|
||||
.map(|line_range| api::FileContentLineRange {
|
||||
start: line_range.start as u32,
|
||||
end: line_range.end as u32,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SkillScope> for api::skill_descriptor::Scope {
|
||||
fn from(scope: SkillScope) -> Self {
|
||||
let scope_type: api::skill_descriptor::scope::Type = match scope {
|
||||
SkillScope::Home => api::skill_descriptor::scope::Type::Home(()),
|
||||
SkillScope::Project => api::skill_descriptor::scope::Type::Project(()),
|
||||
SkillScope::Bundled => api::skill_descriptor::scope::Type::Bundled(()),
|
||||
};
|
||||
|
||||
api::skill_descriptor::Scope {
|
||||
r#type: Some(scope_type),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SkillProvider> for api::skill_descriptor::Provider {
|
||||
fn from(scope: SkillProvider) -> Self {
|
||||
let provider_type: api::skill_descriptor::provider::Type = match scope {
|
||||
SkillProvider::Warp => api::skill_descriptor::provider::Type::Warp(()),
|
||||
SkillProvider::Agents => api::skill_descriptor::provider::Type::Agents(()),
|
||||
SkillProvider::Claude => api::skill_descriptor::provider::Type::Claude(()),
|
||||
SkillProvider::Codex => api::skill_descriptor::provider::Type::Codex(()),
|
||||
SkillProvider::Cursor => api::skill_descriptor::provider::Type::Cursor(()),
|
||||
SkillProvider::Gemini => api::skill_descriptor::provider::Type::Gemini(()),
|
||||
SkillProvider::Copilot => api::skill_descriptor::provider::Type::Copilot(()),
|
||||
SkillProvider::Droid => api::skill_descriptor::provider::Type::Droid(()),
|
||||
SkillProvider::Github => api::skill_descriptor::provider::Type::Github(()),
|
||||
SkillProvider::OpenCode => api::skill_descriptor::provider::Type::OpenCode(()),
|
||||
};
|
||||
|
||||
api::skill_descriptor::Provider {
|
||||
r#type: Some(provider_type),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<api::Skill> for ParsedSkill {
|
||||
type Error = SkillConversionError;
|
||||
|
||||
fn try_from(api_skill: api::Skill) -> Result<Self, Self::Error> {
|
||||
let Some(descriptor) = api_skill.descriptor else {
|
||||
return Err(SkillConversionError::MissingDescriptor);
|
||||
};
|
||||
let Some(file_content) = api_skill.content else {
|
||||
return Err(SkillConversionError::MissingContent);
|
||||
};
|
||||
let Some(skill_reference) = descriptor.skill_reference else {
|
||||
return Err(SkillConversionError::MissingReference);
|
||||
};
|
||||
// TODO(pei): Once we refactor ParsedSkill to use SkillDescriptor,
|
||||
// we can pass forward the reference directly to ParsedSkill
|
||||
let path = match skill_reference {
|
||||
api::skill_descriptor::SkillReference::Path(path) => path,
|
||||
_ => "".to_string(), // This is ok only because we don't use the path
|
||||
};
|
||||
|
||||
let Some(Ok(scope)) = descriptor.scope.map(convert_scope) else {
|
||||
return Err(SkillConversionError::ScopeInvalid);
|
||||
};
|
||||
|
||||
let Some(Ok(provider)) = descriptor.provider.map(convert_provider) else {
|
||||
return Err(SkillConversionError::ProviderInvalid);
|
||||
};
|
||||
|
||||
let context: FileContext = file_content.into();
|
||||
let AnyFileContent::StringContent(content) = context.content else {
|
||||
return Err(SkillConversionError::ContentInvalid);
|
||||
};
|
||||
|
||||
let line_range = context.line_range.as_ref();
|
||||
|
||||
Ok(ParsedSkill {
|
||||
path: PathBuf::from(&path),
|
||||
name: descriptor.name,
|
||||
description: descriptor.description,
|
||||
content,
|
||||
line_range: line_range.cloned(),
|
||||
scope,
|
||||
provider,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_scope(scope: api::skill_descriptor::Scope) -> Result<SkillScope, SkillConversionError> {
|
||||
let Some(scope_type) = scope.r#type else {
|
||||
return Err(SkillConversionError::ScopeInvalid);
|
||||
};
|
||||
|
||||
match scope_type {
|
||||
api::skill_descriptor::scope::Type::Home(_) => Ok(SkillScope::Home),
|
||||
api::skill_descriptor::scope::Type::Project(_) => Ok(SkillScope::Project),
|
||||
api::skill_descriptor::scope::Type::Bundled(_) => Ok(SkillScope::Bundled),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_provider(
|
||||
provider: api::skill_descriptor::Provider,
|
||||
) -> Result<SkillProvider, SkillConversionError> {
|
||||
let Some(provider_type) = provider.r#type else {
|
||||
return Err(SkillConversionError::ProviderInvalid);
|
||||
};
|
||||
|
||||
match provider_type {
|
||||
api::skill_descriptor::provider::Type::Warp(_) => Ok(SkillProvider::Warp),
|
||||
api::skill_descriptor::provider::Type::Agents(_) => Ok(SkillProvider::Agents),
|
||||
api::skill_descriptor::provider::Type::Claude(_) => Ok(SkillProvider::Claude),
|
||||
api::skill_descriptor::provider::Type::Codex(_) => Ok(SkillProvider::Codex),
|
||||
api::skill_descriptor::provider::Type::Cursor(_) => Ok(SkillProvider::Cursor),
|
||||
api::skill_descriptor::provider::Type::Gemini(_) => Ok(SkillProvider::Gemini),
|
||||
api::skill_descriptor::provider::Type::Copilot(_) => Ok(SkillProvider::Copilot),
|
||||
api::skill_descriptor::provider::Type::Droid(_) => Ok(SkillProvider::Droid),
|
||||
api::skill_descriptor::provider::Type::Github(_) => Ok(SkillProvider::Github),
|
||||
api::skill_descriptor::provider::Type::OpenCode(_) => Ok(SkillProvider::OpenCode),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
mod conversion;
|
||||
mod parse_skill;
|
||||
mod parser;
|
||||
mod read_skills;
|
||||
mod skill_provider;
|
||||
mod skill_reference;
|
||||
|
||||
pub use parse_skill::{parse_bundled_skill, parse_skill, 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,
|
||||
};
|
||||
pub use skill_reference::SkillReference;
|
||||
@@ -0,0 +1,206 @@
|
||||
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;
|
||||
|
||||
const MAX_SKILL_DESCRIPTION_CHARS: usize = 512;
|
||||
|
||||
lazy_static! {
|
||||
static ref BLOCK_SEPARATOR: Regex =
|
||||
Regex::new(r"\n\s*\n").expect("Block separator regex should be valid");
|
||||
static ref INCOMPLETE_SENTENCE: Regex =
|
||||
Regex::new(r"[^.!?]*$").expect("Incomplete sentence regex should be valid");
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ParseSkillError {
|
||||
/// This should never happen in practice since we would never read the skill
|
||||
/// file to begin with if the path didn't have a valid parent directory.
|
||||
#[error("Could not derive skill name from path")]
|
||||
CouldNotDeriveSkillNameFromPath,
|
||||
}
|
||||
|
||||
/// Represents a parsed skill with validated fields
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ParsedSkill {
|
||||
pub path: PathBuf,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// The entire content of the file (including front matter)
|
||||
pub content: String,
|
||||
/// The line range where the markdown content (without front matter) is located (1-indexed)
|
||||
/// None if there is no front matter (content is the entire file)
|
||||
pub line_range: Option<Range<usize>>,
|
||||
/// The provider of the skill (Agents, Claude, Codex, or Warp), determined from the path.
|
||||
pub provider: SkillProvider,
|
||||
/// The scope of the skill (home directory vs project directory).
|
||||
pub scope: SkillScope,
|
||||
}
|
||||
|
||||
impl ParsedSkill {
|
||||
/// Returns true if this skill is bundled with Warp (not a user-editable file).
|
||||
pub fn is_bundled(&self) -> bool {
|
||||
self.scope == SkillScope::Bundled
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ParsedSkill {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Skill: {}", self.path.display())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a skill markdown file and validate required fields
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - Path to the skill markdown file to parse
|
||||
///
|
||||
/// # 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 scope = get_scope_for_path(path);
|
||||
parse_skill_internal(path, provider, scope)
|
||||
}
|
||||
|
||||
/// Parse a bundled skill markdown file.
|
||||
///
|
||||
/// Unlike `parse_skill`, this function does not require the path to match a known
|
||||
/// skill provider directory. Bundled skills are always assigned `SkillProvider::Warp`
|
||||
/// and `SkillScope::Bundled`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - Path to the skill markdown file to parse
|
||||
///
|
||||
/// # 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)
|
||||
}
|
||||
|
||||
fn parse_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,
|
||||
provider,
|
||||
scope,
|
||||
})
|
||||
}
|
||||
|
||||
fn derive_skill_name_from_path(path: &Path) -> Result<String> {
|
||||
path.parent()
|
||||
.and_then(|parent| parent.file_name())
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.to_string())
|
||||
.ok_or(ParseSkillError::CouldNotDeriveSkillNameFromPath.into())
|
||||
}
|
||||
|
||||
fn derive_description_from_content(
|
||||
content: &str,
|
||||
line_range: Option<&Range<usize>>,
|
||||
) -> Option<String> {
|
||||
first_paragraph_from_markdown(&extract_markdown_body(content, line_range))
|
||||
}
|
||||
|
||||
fn extract_markdown_body(content: &str, line_range: Option<&Range<usize>>) -> String {
|
||||
let Some(line_range) = line_range else {
|
||||
return content.to_string();
|
||||
};
|
||||
|
||||
let start = line_range.start.saturating_sub(1);
|
||||
let end = line_range.end.saturating_sub(1);
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
if start >= lines.len() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let end = end.min(lines.len());
|
||||
lines[start..end].join("\n")
|
||||
}
|
||||
|
||||
fn first_paragraph_from_markdown(markdown: &str) -> Option<String> {
|
||||
for block in BLOCK_SEPARATOR.split(markdown) {
|
||||
let paragraph: String = block
|
||||
.lines()
|
||||
.map(|line| line.trim())
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let paragraph = paragraph.trim();
|
||||
if !paragraph.is_empty() {
|
||||
return Some(paragraph.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn truncate_skill_description(description: &str) -> String {
|
||||
let description = description.trim();
|
||||
if description.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let chars: Vec<char> = description.chars().collect();
|
||||
if chars.len() <= MAX_SKILL_DESCRIPTION_CHARS {
|
||||
return description.to_string();
|
||||
}
|
||||
|
||||
let truncated: String = chars[..MAX_SKILL_DESCRIPTION_CHARS].iter().collect();
|
||||
|
||||
// Drop the trailing incomplete sentence using regex
|
||||
let at_sentence = INCOMPLETE_SENTENCE
|
||||
.replace(&truncated, "")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !at_sentence.is_empty() {
|
||||
return at_sentence;
|
||||
}
|
||||
|
||||
// No sentence boundary found — fall back to word boundary
|
||||
truncated
|
||||
.rfind(char::is_whitespace)
|
||||
.map(|pos| truncated[..pos].trim().to_string())
|
||||
.unwrap_or(truncated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "parse_skill_test.rs"]
|
||||
mod parse_skill_test;
|
||||
@@ -0,0 +1,248 @@
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Creates a temporary skill file in a .agents/skills directory
|
||||
fn create_temp_skill_file(content: &str) -> (TempDir, PathBuf) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skill_dir = temp_dir.path().join(".agents/skills/test-skill");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
let skill_file = skill_dir.join("SKILL.md");
|
||||
std::fs::write(&skill_file, content).unwrap();
|
||||
(temp_dir, skill_file)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_with_front_matter() {
|
||||
let content = r#"---
|
||||
name: your-skill-name
|
||||
description: Brief description of what this Skill does and when to use it
|
||||
---
|
||||
|
||||
# Your Skill Name
|
||||
|
||||
## Instructions
|
||||
Provide clear, step-by-step guidance for Claude.
|
||||
|
||||
## Examples
|
||||
Show concrete examples of using this Skill.
|
||||
"#;
|
||||
|
||||
let (_temp_dir, skill_file) = create_temp_skill_file(content);
|
||||
let result = parse_skill(&skill_file).unwrap();
|
||||
|
||||
assert_eq!(result.name, "your-skill-name");
|
||||
assert_eq!(
|
||||
result.description,
|
||||
"Brief description of what this Skill does and when to use it"
|
||||
);
|
||||
// Content should include both front matter and markdown
|
||||
assert!(result.content.contains("# Your Skill Name"));
|
||||
assert!(result.content.contains("## Instructions"));
|
||||
assert!(result.content.contains("## Examples"));
|
||||
assert!(result.content.contains("---"));
|
||||
assert!(result.content.contains("name: your-skill-name"));
|
||||
// Verify line_range is set (1-indexed)
|
||||
// Front matter is lines 1-4, markdown content starts at line 5
|
||||
// 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_missing_name_falls_back_to_directory_name() {
|
||||
let content = r#"---
|
||||
description: Some description
|
||||
---
|
||||
|
||||
# Content
|
||||
|
||||
This paragraph is for body parsing.
|
||||
"#;
|
||||
|
||||
let (_temp_dir, skill_file) = create_temp_skill_file(content);
|
||||
let result = parse_skill(&skill_file).unwrap();
|
||||
|
||||
assert_eq!(result.name, "test-skill");
|
||||
assert_eq!(result.description, "Some description");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_missing_description_falls_back_to_first_paragraph() {
|
||||
let content = r#"---
|
||||
name: some-skill
|
||||
---
|
||||
|
||||
# Heading
|
||||
|
||||
This is the first paragraph.
|
||||
Still first paragraph.
|
||||
|
||||
Second paragraph.
|
||||
"#;
|
||||
|
||||
let (_temp_dir, skill_file) = create_temp_skill_file(content);
|
||||
let result = parse_skill(&skill_file).unwrap();
|
||||
|
||||
assert_eq!(result.name, "some-skill");
|
||||
assert_eq!(
|
||||
result.description,
|
||||
"This is the first paragraph. Still first paragraph."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_missing_both_name_and_description_falls_back() {
|
||||
let content = "---\n\n---\n\n# Heading\n\nThis is the first paragraph.\nStill first paragraph.\n\nSecond paragraph.\n";
|
||||
|
||||
let (_temp_dir, skill_file) = create_temp_skill_file(content);
|
||||
let result = parse_skill(&skill_file).unwrap();
|
||||
|
||||
assert_eq!(result.name, "test-skill");
|
||||
assert_eq!(
|
||||
result.description,
|
||||
"This is the first paragraph. Still first paragraph."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_no_front_matter_falls_back_to_derived_values() {
|
||||
let content = r#"# Just Content
|
||||
|
||||
This is just markdown content without any front matter.
|
||||
"#;
|
||||
|
||||
let (_temp_dir, skill_file) = create_temp_skill_file(content);
|
||||
let result = parse_skill(&skill_file).unwrap();
|
||||
|
||||
assert_eq!(result.name, "test-skill");
|
||||
assert_eq!(
|
||||
result.description,
|
||||
"This is just markdown content without any front matter."
|
||||
);
|
||||
assert!(result.line_range.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_no_skill_provider_defaults_to_agents() {
|
||||
let content = r#"---
|
||||
name: some-skill
|
||||
description: Some description
|
||||
---
|
||||
|
||||
# Content
|
||||
"#;
|
||||
|
||||
// Create a temp file without a skill provider directory structure
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let invalid_file = temp_dir.path().join("invalid.md");
|
||||
std::fs::write(&invalid_file, content).unwrap();
|
||||
|
||||
let result = parse_skill(&invalid_file).unwrap();
|
||||
|
||||
assert_eq!(result.name, "some-skill");
|
||||
assert_eq!(result.description, "Some description");
|
||||
assert_eq!(result.provider, SkillProvider::Agents);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_truncates_long_fallback_description_at_sentence_boundary() {
|
||||
let first_sentence = format!("{}.", "a".repeat(450));
|
||||
let content = format!(
|
||||
r#"---
|
||||
name: some-skill
|
||||
---
|
||||
|
||||
{} {}
|
||||
"#,
|
||||
first_sentence,
|
||||
"b".repeat(200)
|
||||
);
|
||||
|
||||
let (_temp_dir, skill_file) = create_temp_skill_file(&content);
|
||||
let result = parse_skill(&skill_file).unwrap();
|
||||
|
||||
assert_eq!(result.description, first_sentence);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_truncates_long_fallback_description_at_word_boundary() {
|
||||
let content = format!(
|
||||
r#"---
|
||||
name: some-skill
|
||||
---
|
||||
|
||||
{}
|
||||
"#,
|
||||
"word ".repeat(200)
|
||||
);
|
||||
|
||||
let (_temp_dir, skill_file) = create_temp_skill_file(&content);
|
||||
let result = parse_skill(&skill_file).unwrap();
|
||||
|
||||
assert!(result.description.chars().count() <= MAX_SKILL_DESCRIPTION_CHARS);
|
||||
assert!(!result.description.ends_with(' '));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_truncates_fallback_description_with_hard_cut() {
|
||||
let content = format!(
|
||||
"---\nname: some-skill\n---\n\n{}",
|
||||
"x".repeat(MAX_SKILL_DESCRIPTION_CHARS + 100)
|
||||
);
|
||||
|
||||
let (_temp_dir, skill_file) = create_temp_skill_file(&content);
|
||||
let result = parse_skill(&skill_file).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result.description.chars().count(),
|
||||
MAX_SKILL_DESCRIPTION_CHARS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_does_not_truncate_user_provided_description() {
|
||||
let description = format!("{} {}", "a".repeat(450), "b".repeat(200));
|
||||
let content = format!(
|
||||
r#"---
|
||||
name: some-skill
|
||||
description: "{}"
|
||||
---
|
||||
|
||||
# Content
|
||||
"#,
|
||||
description
|
||||
);
|
||||
|
||||
let (_temp_dir, skill_file) = create_temp_skill_file(&content);
|
||||
let result = parse_skill(&skill_file).unwrap();
|
||||
|
||||
assert_eq!(result.description, description);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncation_does_not_cut_mid_word_like_filename() {
|
||||
// "abc.def" has no sentence boundary (no whitespace after punctuation),
|
||||
// so truncation should fall back to word boundary or hard cut.
|
||||
let long_word = "abc.def".repeat(100);
|
||||
let content = format!("---\nname: some-skill\n---\n\n{long_word}");
|
||||
|
||||
let (_temp_dir, skill_file) = create_temp_skill_file(&content);
|
||||
let result = parse_skill(&skill_file).unwrap();
|
||||
|
||||
assert!(result.description.chars().count() <= MAX_SKILL_DESCRIPTION_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncation_cuts_at_sentence_boundary() {
|
||||
let first_sentence = "This is a sentence.";
|
||||
let second_sentence_start = " ".to_string() + &"b".repeat(600);
|
||||
let content = format!("---\nname: some-skill\n---\n\n{first_sentence}{second_sentence_start}");
|
||||
|
||||
let (_temp_dir, skill_file) = create_temp_skill_file(&content);
|
||||
let result = parse_skill(&skill_file).unwrap();
|
||||
|
||||
assert_eq!(result.description, "This is a sentence.");
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ParsedMarkdown {
|
||||
/// The YAML front matter parsed as a map
|
||||
/// For Skills, the front matter is always a single-level map with string keys and string values
|
||||
pub front_matter: HashMap<String, String>,
|
||||
/// The entire content of the file
|
||||
pub content: String,
|
||||
/// The line range where the markdown content (without front matter) is located (1-indexed)
|
||||
/// None if there is no front matter (content is the entire file)
|
||||
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> {
|
||||
// Regex to match YAML front matter at the start of the file
|
||||
// Handles both LF (\n) and CRLF (\r\n) line endings
|
||||
// Allows leading whitespace (spaces, tabs, newlines) before the opening ---
|
||||
// Allows trailing spaces/tabs after --- markers
|
||||
// Pattern: (optional whitespace) --- (optional spaces/tabs) (line ending) (content) (line ending) --- (optional spaces/tabs) (line ending)
|
||||
let front_matter_regex =
|
||||
Regex::new(r"(?ms)\A\s*---[ \t]*\r?\n(.*?)\r?\n---[ \t]*\r?\n").unwrap();
|
||||
let captures = front_matter_regex.captures(content);
|
||||
|
||||
if let Some(captures) = captures {
|
||||
// Extract the YAML section (first capture group) and trim to handle extra blank lines
|
||||
let yaml_str = captures.get(1).unwrap().as_str().trim();
|
||||
|
||||
// Parse the YAML into a map (empty front matter is valid — just yields no keys)
|
||||
let front_matter = if yaml_str.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
let yaml_value: Value =
|
||||
serde_yaml::from_str(yaml_str).context("Failed to parse YAML front matter")?;
|
||||
match yaml_value {
|
||||
Value::Mapping(map) => map
|
||||
.iter()
|
||||
.filter_map(|(key, value)| {
|
||||
if let Value::String(key_str) = key {
|
||||
if let Value::String(value_str) = value {
|
||||
return Some((key_str.clone(), value_str.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
})
|
||||
.collect(),
|
||||
_ => HashMap::new(),
|
||||
}
|
||||
};
|
||||
|
||||
// Get the content after the front matter
|
||||
let content_start = captures.get(0).unwrap().end();
|
||||
|
||||
// Calculate line range for the markdown content (without front matter)
|
||||
// Line numbers are 1-indexed, so we add 1
|
||||
let lines_before_content = content[..content_start].lines().count();
|
||||
let total_lines = content.lines().count();
|
||||
let line_range = Some((lines_before_content + 1)..(total_lines + 1));
|
||||
|
||||
Ok(ParsedMarkdown {
|
||||
front_matter,
|
||||
content: content.to_string(),
|
||||
line_range,
|
||||
})
|
||||
} else {
|
||||
// No front matter found - content is the entire file, so line_range is None
|
||||
Ok(ParsedMarkdown {
|
||||
front_matter: HashMap::new(),
|
||||
content: content.to_string(),
|
||||
line_range: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "parser_test.rs"]
|
||||
mod parser_test;
|
||||
@@ -0,0 +1,203 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_without_front_matter() {
|
||||
let content = r#"# Hello World
|
||||
|
||||
This is just markdown content without front matter.
|
||||
"#;
|
||||
|
||||
let result = parse_markdown_content(content).unwrap();
|
||||
|
||||
assert!(result.front_matter.is_empty());
|
||||
assert_eq!(result.content, content);
|
||||
// When there's no front matter, line_range should be None
|
||||
assert_eq!(result.line_range, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_front_matter() {
|
||||
let content = r#"---
|
||||
---
|
||||
|
||||
# Content
|
||||
"#;
|
||||
|
||||
let result = parse_markdown_content(content).unwrap();
|
||||
|
||||
// Empty front matter (---\n---) doesn't match the regex, so it's treated as no front matter
|
||||
assert!(result.front_matter.is_empty());
|
||||
assert_eq!(result.content, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_with_crlf_line_endings() {
|
||||
let content =
|
||||
"---\r\nname: test-skill\r\ndescription: Test description\r\n---\r\n\r\n# Content\r\n";
|
||||
|
||||
let result = parse_markdown_content(content).unwrap();
|
||||
|
||||
assert_eq!(result.front_matter.len(), 2);
|
||||
assert_eq!(result.front_matter.get("name").unwrap(), "test-skill");
|
||||
assert_eq!(
|
||||
result.front_matter.get("description").unwrap(),
|
||||
"Test description"
|
||||
);
|
||||
// Content should include the entire file (including front matter)
|
||||
assert_eq!(result.content, content);
|
||||
assert!(result.content.contains("# Content"));
|
||||
assert!(result.content.contains("name: test-skill"));
|
||||
// Line range should represent the markdown content after front matter (1-indexed)
|
||||
// Front matter is 4 lines (1-4), markdown content starts at line 5
|
||||
assert_eq!(result.line_range, Some(5..7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_with_trailing_spaces_after_delimiters() {
|
||||
let content =
|
||||
"--- \nname: test-skill\ndescription: Test description\n--- \t \n\n# Content\n";
|
||||
|
||||
let result = parse_markdown_content(content).unwrap();
|
||||
|
||||
assert_eq!(result.front_matter.len(), 2);
|
||||
assert_eq!(result.front_matter.get("name").unwrap(), "test-skill");
|
||||
assert_eq!(
|
||||
result.front_matter.get("description").unwrap(),
|
||||
"Test description"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_with_extra_blank_lines_in_front_matter() {
|
||||
let content = r#"---
|
||||
|
||||
name: test-skill
|
||||
description: Test description
|
||||
|
||||
---
|
||||
|
||||
# Content
|
||||
"#;
|
||||
|
||||
let result = parse_markdown_content(content).unwrap();
|
||||
|
||||
assert_eq!(result.front_matter.len(), 2);
|
||||
assert_eq!(result.front_matter.get("name").unwrap(), "test-skill");
|
||||
assert_eq!(
|
||||
result.front_matter.get("description").unwrap(),
|
||||
"Test description"
|
||||
);
|
||||
assert!(result.content.contains("# Content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_with_mixed_crlf_and_extra_whitespace() {
|
||||
let content = "--- \r\n\r\nname: test-skill\r\ndescription: Test description\r\n\r\n---\t\r\n\r\n# Content\r\n";
|
||||
|
||||
let result = parse_markdown_content(content).unwrap();
|
||||
|
||||
assert_eq!(result.front_matter.len(), 2);
|
||||
assert_eq!(result.front_matter.get("name").unwrap(), "test-skill");
|
||||
assert_eq!(
|
||||
result.front_matter.get("description").unwrap(),
|
||||
"Test description"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_with_tabs_and_spaces() {
|
||||
let content =
|
||||
"---\t \t\nname: test-skill\ndescription: Test description\n--- \t\n\n# Content\n";
|
||||
|
||||
let result = parse_markdown_content(content).unwrap();
|
||||
|
||||
assert_eq!(result.front_matter.len(), 2);
|
||||
assert_eq!(result.front_matter.get("name").unwrap(), "test-skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crlf_without_proper_front_matter() {
|
||||
let content = "# Hello World\r\n\r\nThis is just markdown content.\r\n";
|
||||
|
||||
let result = parse_markdown_content(content).unwrap();
|
||||
|
||||
assert!(result.front_matter.is_empty());
|
||||
assert_eq!(result.content, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_with_leading_whitespace_before_front_matter() {
|
||||
let content = r#"
|
||||
|
||||
---
|
||||
name: test-skill
|
||||
description: Test description
|
||||
---
|
||||
|
||||
# Content
|
||||
"#;
|
||||
|
||||
let result = parse_markdown_content(content).unwrap();
|
||||
|
||||
assert_eq!(result.front_matter.len(), 2);
|
||||
assert_eq!(result.front_matter.get("name").unwrap(), "test-skill");
|
||||
assert_eq!(
|
||||
result.front_matter.get("description").unwrap(),
|
||||
"Test description"
|
||||
);
|
||||
|
||||
// Line numbers (1-indexed):
|
||||
// Line 1: (empty from raw string start)
|
||||
// Line 2: (empty)
|
||||
// Line 3: ---
|
||||
// Line 4: name: test-skill
|
||||
// Line 5: description: Test description
|
||||
// Line 6: ---
|
||||
// Line 7: (empty)
|
||||
// Line 8: # Content
|
||||
assert_eq!(result.line_range, Some(7..9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_with_spaces_and_newlines_before_front_matter() {
|
||||
let content =
|
||||
" \n\t\n---\nname: test-skill\ndescription: Test description\n---\n\n# Content\n";
|
||||
|
||||
let result = parse_markdown_content(content).unwrap();
|
||||
|
||||
assert_eq!(result.front_matter.len(), 2);
|
||||
assert_eq!(result.front_matter.get("name").unwrap(), "test-skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_includes_front_matter_and_line_range() {
|
||||
let content = r#"---
|
||||
name: my-skill
|
||||
description: My skill description
|
||||
---
|
||||
|
||||
# My Skill
|
||||
|
||||
This is the skill content.
|
||||
"#;
|
||||
|
||||
let result = parse_markdown_content(content).unwrap();
|
||||
|
||||
// Verify front matter is parsed correctly
|
||||
assert_eq!(result.front_matter.len(), 2);
|
||||
assert_eq!(result.front_matter.get("name").unwrap(), "my-skill");
|
||||
assert_eq!(
|
||||
result.front_matter.get("description").unwrap(),
|
||||
"My skill description"
|
||||
);
|
||||
|
||||
// Verify content includes the entire file (front matter + markdown)
|
||||
assert_eq!(result.content, content);
|
||||
assert!(result.content.contains("---"));
|
||||
assert!(result.content.contains("name: my-skill"));
|
||||
assert!(result.content.contains("# My Skill"));
|
||||
|
||||
// Verify line_range points to just the markdown content (after front matter, 1-indexed)
|
||||
// Front matter is lines 1-4, markdown content starts at line 5
|
||||
assert_eq!(result.line_range, Some(5..9));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::parse_skill::{parse_skill, ParsedSkill};
|
||||
|
||||
/// Read all skills from a directory containing skill subdirectories
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - The path to a skills directory, e.g. `.claude/skills`
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Vec<ParsedSkill>` - List of successfully parsed skills (invalid files and errors are silently ignored)
|
||||
pub fn read_skills(path: &Path) -> Vec<ParsedSkill> {
|
||||
let mut skills = Vec::new();
|
||||
|
||||
// Read all entries in the directory, return empty vec on error
|
||||
let Ok(entries) = fs::read_dir(path) else {
|
||||
return skills;
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
// Skip entries that fail to read
|
||||
let Ok(entry) = entry else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let entry_path = entry.path();
|
||||
|
||||
// Only process directories
|
||||
if !entry_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look for SKILL.md file in the subdirectory
|
||||
let skill_file_path = entry_path.join("SKILL.md");
|
||||
|
||||
if skill_file_path.exists() {
|
||||
// Attempt to parse the skill file, ignoring errors
|
||||
if let Ok(parsed_skill) = parse_skill(&skill_file_path) {
|
||||
skills.push(parsed_skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
skills
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "read_skills_test.rs"]
|
||||
mod read_skills_test;
|
||||
@@ -0,0 +1,221 @@
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_read_skills_with_valid_skills() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
// Create .agents/skills directory structure so skills can have a valid provider
|
||||
let skills_dir = temp_dir.path().join(".agents/skills");
|
||||
fs::create_dir_all(&skills_dir).unwrap();
|
||||
|
||||
// Create first skill directory with valid SKILL.md
|
||||
let skill1_dir = skills_dir.join("skill1");
|
||||
fs::create_dir(&skill1_dir).unwrap();
|
||||
fs::write(
|
||||
skill1_dir.join("SKILL.md"),
|
||||
r#"---
|
||||
name: test-skill-1
|
||||
description: First test skill
|
||||
---
|
||||
|
||||
# Test Skill 1
|
||||
This is the first test skill.
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create second skill directory with valid SKILL.md
|
||||
let skill2_dir = skills_dir.join("skill2");
|
||||
fs::create_dir(&skill2_dir).unwrap();
|
||||
fs::write(
|
||||
skill2_dir.join("SKILL.md"),
|
||||
r#"---
|
||||
name: test-skill-2
|
||||
description: Second test skill
|
||||
---
|
||||
|
||||
# Test Skill 2
|
||||
This is the second test skill.
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = read_skills(&skills_dir);
|
||||
|
||||
assert_eq!(skills.len(), 2);
|
||||
|
||||
// Find each skill by name
|
||||
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()
|
||||
);
|
||||
assert_eq!(skill1.description, "First test skill");
|
||||
assert!(skill1.content.contains("# Test Skill 1"));
|
||||
assert!(skill1.content.contains("---"));
|
||||
assert!(skill1.content.contains("name: test-skill-1"));
|
||||
assert_eq!(skill1.line_range, Some(5..8)); // Front matter is lines 1-4, markdown starts at line 5
|
||||
|
||||
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()
|
||||
);
|
||||
assert_eq!(skill2.description, "Second test skill");
|
||||
assert!(skill2.content.contains("# Test Skill 2"));
|
||||
assert!(skill2.content.contains("---"));
|
||||
assert!(skill2.content.contains("name: test-skill-2"));
|
||||
assert_eq!(skill2.line_range, Some(5..8)); // Front matter is lines 1-4, markdown starts at line 5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_skills_ignores_only_truly_invalid_files() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
// Create .agents/skills directory structure so skills can have a valid provider
|
||||
let skills_dir = temp_dir.path().join(".agents/skills");
|
||||
fs::create_dir_all(&skills_dir).unwrap();
|
||||
|
||||
// Create valid skill
|
||||
let valid_skill_dir = skills_dir.join("valid-skill");
|
||||
fs::create_dir(&valid_skill_dir).unwrap();
|
||||
fs::write(
|
||||
valid_skill_dir.join("SKILL.md"),
|
||||
r#"---
|
||||
name: valid-skill
|
||||
description: Valid skill
|
||||
---
|
||||
|
||||
# Valid Skill
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create skill missing name (now valid via directory-name fallback)
|
||||
let invalid_skill_dir = skills_dir.join("invalid-skill");
|
||||
fs::create_dir(&invalid_skill_dir).unwrap();
|
||||
fs::write(
|
||||
invalid_skill_dir.join("SKILL.md"),
|
||||
r#"---
|
||||
description: Invalid skill missing name
|
||||
---
|
||||
|
||||
# Invalid Skill
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create skill with no front matter (now valid via full fallback)
|
||||
let no_frontmatter_dir = skills_dir.join("no-frontmatter-skill");
|
||||
fs::create_dir(&no_frontmatter_dir).unwrap();
|
||||
fs::write(
|
||||
no_frontmatter_dir.join("SKILL.md"),
|
||||
r#"# No Front Matter Skill
|
||||
|
||||
No front matter here.
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = read_skills(&skills_dir);
|
||||
|
||||
// All three skills should be returned — none are truly invalid
|
||||
assert_eq!(skills.len(), 3);
|
||||
|
||||
let valid_skill = skills.iter().find(|s| s.name == "valid-skill").unwrap();
|
||||
assert_eq!(valid_skill.description, "Valid skill");
|
||||
assert!(valid_skill.content.contains("---"));
|
||||
assert_eq!(valid_skill.line_range, Some(5..7));
|
||||
|
||||
let fallback_name_skill = skills.iter().find(|s| s.name == "invalid-skill").unwrap();
|
||||
assert_eq!(
|
||||
fallback_name_skill.description,
|
||||
"Invalid skill missing name"
|
||||
);
|
||||
assert!(fallback_name_skill.content.contains("# Invalid Skill"));
|
||||
|
||||
let no_fm_skill = skills
|
||||
.iter()
|
||||
.find(|s| s.name == "no-frontmatter-skill")
|
||||
.unwrap();
|
||||
assert_eq!(no_fm_skill.description, "No front matter here.");
|
||||
assert!(no_fm_skill.line_range.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_skills_empty_directory() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let skills_dir = temp_dir.path();
|
||||
|
||||
let skills = read_skills(skills_dir);
|
||||
|
||||
assert_eq!(skills.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_skills_no_skill_files() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
let skills_dir = temp_dir.path();
|
||||
|
||||
// Create directories without SKILL.md files
|
||||
let dir1 = skills_dir.join("dir1");
|
||||
fs::create_dir(&dir1).unwrap();
|
||||
|
||||
let dir2 = skills_dir.join("dir2");
|
||||
fs::create_dir(&dir2).unwrap();
|
||||
fs::write(dir2.join("README.md"), "Not a skill file").unwrap();
|
||||
|
||||
let skills = read_skills(skills_dir);
|
||||
|
||||
assert_eq!(skills.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_skills_ignores_files_in_root() {
|
||||
let temp_dir = tempdir().unwrap();
|
||||
// Create .agents/skills directory structure so skills can have a valid provider
|
||||
let skills_dir = temp_dir.path().join(".agents/skills");
|
||||
fs::create_dir_all(&skills_dir).unwrap();
|
||||
|
||||
// Create a valid skill in a subdirectory
|
||||
let skill_dir = skills_dir.join("valid-skill");
|
||||
fs::create_dir(&skill_dir).unwrap();
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
r#"---
|
||||
name: valid-skill
|
||||
description: Valid skill in subdirectory
|
||||
---
|
||||
|
||||
# Valid Skill
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create a SKILL.md file in the root directory (should be ignored)
|
||||
fs::write(
|
||||
skills_dir.join("SKILL.md"),
|
||||
r#"---
|
||||
name: root-skill
|
||||
description: This should be ignored
|
||||
---
|
||||
|
||||
# Root Skill
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = read_skills(&skills_dir);
|
||||
|
||||
// Only the skill in the subdirectory should be returned
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "valid-skill");
|
||||
assert_eq!(skills[0].line_range, Some(5..7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_skills_nonexistent_directory() {
|
||||
let skills = read_skills(Path::new("/nonexistent/path/that/does/not/exist"));
|
||||
|
||||
assert_eq!(skills.len(), 0);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Skill provider definitions and utilities.
|
||||
//!
|
||||
//! 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 serde::{Deserialize, Serialize};
|
||||
|
||||
use strum_macros::{Display, EnumString, VariantNames};
|
||||
use warp_core::ui::color::CLAUDE_ORANGE;
|
||||
use warp_core::ui::icons::Icon;
|
||||
use warp_core::ui::theme::Fill;
|
||||
|
||||
/// Represents a skill provider/origin (Agents, Claude, Codex, or Warp).
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Display,
|
||||
EnumString,
|
||||
VariantNames,
|
||||
)]
|
||||
pub enum SkillProvider {
|
||||
Warp,
|
||||
Agents,
|
||||
Claude,
|
||||
Codex,
|
||||
Cursor,
|
||||
Gemini,
|
||||
Copilot,
|
||||
Droid,
|
||||
Github,
|
||||
OpenCode,
|
||||
}
|
||||
|
||||
/// Represents the scope of a skill (home directory vs project directory).
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Default,
|
||||
Display,
|
||||
EnumString,
|
||||
VariantNames,
|
||||
)]
|
||||
pub enum SkillScope {
|
||||
/// Skills from the user's home directory (e.g., `~/.agents/skills`).
|
||||
#[default]
|
||||
Home,
|
||||
/// Skills from a project directory (e.g., `./repo/.agents/skills`).
|
||||
Project,
|
||||
/// Bundled skills distributed with Warp.
|
||||
Bundled,
|
||||
}
|
||||
|
||||
/// Definition of a skill provider including its directory path.
|
||||
pub struct SkillProviderDefinition {
|
||||
pub provider: SkillProvider,
|
||||
/// Relative path from root (repo or home), constructed with platform-aware joining.
|
||||
pub skills_path: PathBuf,
|
||||
}
|
||||
|
||||
impl SkillProvider {
|
||||
/// Returns the default icon for this provider.
|
||||
pub fn icon(&self) -> Icon {
|
||||
match self {
|
||||
SkillProvider::Claude => Icon::ClaudeLogo,
|
||||
SkillProvider::Codex => Icon::OpenAILogo,
|
||||
SkillProvider::Gemini => Icon::GeminiLogo,
|
||||
SkillProvider::Droid => Icon::DroidLogo,
|
||||
SkillProvider::OpenCode => Icon::OpenCodeLogo,
|
||||
SkillProvider::Warp
|
||||
| SkillProvider::Agents
|
||||
| SkillProvider::Cursor
|
||||
| SkillProvider::Copilot
|
||||
| SkillProvider::Github => Icon::WarpLogoLight,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the icon fill for this provider, using `fallback` for providers that
|
||||
/// don't require a specific color. Claude uses its branded salmon color instead.
|
||||
pub fn icon_fill(&self, fallback: Fill) -> Fill {
|
||||
match self {
|
||||
SkillProvider::Claude => Fill::Solid(CLAUDE_ORANGE),
|
||||
_ => fallback,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All provider definitions. Order determines precedence (first = highest priority).
|
||||
pub static SKILL_PROVIDER_DEFINITIONS: LazyLock<Vec<SkillProviderDefinition>> =
|
||||
LazyLock::new(|| {
|
||||
vec![
|
||||
SkillProviderDefinition {
|
||||
provider: SkillProvider::Agents,
|
||||
skills_path: PathBuf::from(".agents").join("skills"),
|
||||
},
|
||||
SkillProviderDefinition {
|
||||
provider: SkillProvider::Warp,
|
||||
skills_path: PathBuf::from(".warp").join("skills"),
|
||||
},
|
||||
SkillProviderDefinition {
|
||||
provider: SkillProvider::Claude,
|
||||
skills_path: PathBuf::from(".claude").join("skills"),
|
||||
},
|
||||
SkillProviderDefinition {
|
||||
provider: SkillProvider::Codex,
|
||||
skills_path: PathBuf::from(".codex").join("skills"),
|
||||
},
|
||||
SkillProviderDefinition {
|
||||
provider: SkillProvider::Cursor,
|
||||
skills_path: PathBuf::from(".cursor").join("skills"),
|
||||
},
|
||||
SkillProviderDefinition {
|
||||
provider: SkillProvider::Gemini,
|
||||
skills_path: PathBuf::from(".gemini").join("skills"),
|
||||
},
|
||||
SkillProviderDefinition {
|
||||
provider: SkillProvider::Copilot,
|
||||
skills_path: PathBuf::from(".copilot").join("skills"),
|
||||
},
|
||||
SkillProviderDefinition {
|
||||
provider: SkillProvider::Droid,
|
||||
skills_path: PathBuf::from(".factory").join("skills"),
|
||||
},
|
||||
SkillProviderDefinition {
|
||||
provider: SkillProvider::Github,
|
||||
skills_path: PathBuf::from(".github").join("skills"),
|
||||
},
|
||||
SkillProviderDefinition {
|
||||
provider: SkillProvider::OpenCode,
|
||||
skills_path: PathBuf::from(".opencode").join("skills"),
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
/// Returns the precedence rank of a provider based on its position in [`SKILL_PROVIDER_DEFINITIONS`].
|
||||
pub fn provider_rank(provider: SkillProvider) -> usize {
|
||||
SKILL_PROVIDER_DEFINITIONS
|
||||
.iter()
|
||||
.position(|def| def.provider == provider)
|
||||
// NOTE: Each SkillProvider should map to a unique SkillProviderDefinition
|
||||
// so we should never reach this path.
|
||||
.unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
pub fn home_skills_path(provider: SkillProvider) -> Option<PathBuf> {
|
||||
if provider == SkillProvider::Warp {
|
||||
return warp_core::paths::warp_home_skills_dir();
|
||||
}
|
||||
let definition = SKILL_PROVIDER_DEFINITIONS
|
||||
.iter()
|
||||
.find(|def| def.provider == provider)?;
|
||||
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();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the skill scope (Home or Project) for a given path.
|
||||
/// A skill is considered a "Home" skill if its path starts with the user's home directory.
|
||||
/// Otherwise, it's a "Project" skill.
|
||||
pub fn get_scope_for_path(path: &Path) -> SkillScope {
|
||||
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 SkillScope::Home;
|
||||
}
|
||||
}
|
||||
SkillScope::Project
|
||||
}
|
||||
|
||||
#[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),
|
||||
warp_core::paths::warp_home_skills_dir()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_home_skill_path_is_home_warp_skill() {
|
||||
let Some(warp_home_skills_dir) = warp_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(&path), Some(SkillProvider::Warp));
|
||||
assert_eq!(get_scope_for_path(&path), SkillScope::Home);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fmt, path::PathBuf};
|
||||
|
||||
/// 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),
|
||||
/// A bundled skill distributed with Warp.
|
||||
BundledSkillId(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for SkillReference {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
SkillReference::Path(path) => path.display().fmt(f),
|
||||
SkillReference::BundledSkillId(id) => write!(f, "@warp-skill:{id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SkillReference> for warp_multi_agent_api::skill_descriptor::SkillReference {
|
||||
fn from(reference: SkillReference) -> Self {
|
||||
match reference {
|
||||
SkillReference::Path(path) => {
|
||||
warp_multi_agent_api::skill_descriptor::SkillReference::Path(
|
||||
path.to_string_lossy().to_string(),
|
||||
)
|
||||
}
|
||||
SkillReference::BundledSkillId(id) => {
|
||||
warp_multi_agent_api::skill_descriptor::SkillReference::BundledSkillId(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user