Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+166
View File
@@ -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),
}
}
+14
View File
@@ -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;
+206
View File
@@ -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;
+248
View File
@@ -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.");
}
+101
View File
@@ -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;
+203
View File
@@ -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));
}
+50
View File
@@ -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;
+221
View File
@@ -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);
}
+238
View File
@@ -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);
}
}
+35
View File
@@ -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)
}
}
}
}