Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
use std::path::Path;
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
/// A skill specifier that can reference a skill in a specific repo or search the current directory.
|
||||
///
|
||||
/// The skill identifier (after the optional `repo:` or `org/repo:` prefix) can be either:
|
||||
/// - A **simple skill name** - searched across skill directories with precedence (`.agents/skills/`, `.warp-core/skills/`, `.claude/skills/`, `.codex/skills/`)
|
||||
/// - A **full path to SKILL.md** - resolved directly without precedence
|
||||
///
|
||||
/// # Formats
|
||||
/// - `skill_name` - Simple name, search current directory
|
||||
/// - `skill_path` - Full path (e.g., `.claude/skills/foo/SKILL.md`)
|
||||
/// - `repo:skill_name` - Simple name in specific repo
|
||||
/// - `repo:skill_path` - Full path in specific repo
|
||||
/// - `org/repo:skill_name` - Simple name with org and repo
|
||||
/// - `org/repo:skill_path` - Full path with org and repo
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// Simple skill names (searched with directory precedence):
|
||||
/// ```ignore
|
||||
/// code-review // searches .agents/skills/, .warp-core/skills/, .claude/skills/, .codex/skills/
|
||||
/// warp-internal:code-review // searches in "warp-internal" repo
|
||||
/// warpdotdev/warp-internal:code-review // searches in specific org/repo
|
||||
/// ```
|
||||
///
|
||||
/// Full paths (resolved directly, no precedence):
|
||||
/// ```ignore
|
||||
/// .agents/skills/my-skill/SKILL.md // directly resolves this path
|
||||
/// warp-server:.claude/skills/deploy/SKILL.md // exact path in "warp-server" repo
|
||||
/// warpdotdev/warp-internal:.claude/skills/code-review/SKILL.md // exact path in org/repo
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkillSpec {
|
||||
/// Optional GitHub organization (e.g., "warpdotdev" in "warpdotdev/warp-internal:code-review")
|
||||
pub org: Option<String>,
|
||||
/// Optional repository name (e.g., "warp-internal")
|
||||
pub repo: Option<String>,
|
||||
/// The skill identifier - either a simple name or a full path to SKILL.md.
|
||||
///
|
||||
/// - **Simple name** (e.g., `"code-review"`): Searched across `.agents/skills/`, `.warp-core/skills/`, `.claude/skills/`, `.codex/skills/`
|
||||
/// in precedence order. The name is used to construct paths like `.claude/skills/code-review/SKILL.md`.
|
||||
///
|
||||
/// - **Full path** (e.g., `".claude/skills/code-review/SKILL.md"`): Resolved directly without precedence.
|
||||
/// Detected by presence of path separators (e.g., `/` or `\`).
|
||||
///
|
||||
/// Use [`is_full_path()`](Self::is_full_path) to distinguish between the two formats.
|
||||
pub skill_identifier: String,
|
||||
}
|
||||
|
||||
impl SkillSpec {
|
||||
/// Create a new skill spec with org and repo qualifiers.
|
||||
pub fn with_org_and_repo(org: String, repo: String, skill_identifier: String) -> Self {
|
||||
Self {
|
||||
org: Some(org),
|
||||
repo: Some(repo),
|
||||
skill_identifier,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new skill spec with a repo qualifier.
|
||||
pub fn with_repo(repo: String, skill_identifier: String) -> Self {
|
||||
Self {
|
||||
org: None,
|
||||
repo: Some(repo),
|
||||
skill_identifier,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new skill spec without any qualifier.
|
||||
pub fn without_repo(skill_identifier: String) -> Self {
|
||||
Self {
|
||||
org: None,
|
||||
repo: None,
|
||||
skill_identifier,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if `skill_identifier` is a full path, false if it's a simple skill name.
|
||||
///
|
||||
/// A full path contains path separators (`/` or `\`), such as:
|
||||
/// - `.claude/skills/deploy/SKILL.md`
|
||||
/// - `.agents/skills/my-skill/SKILL.md`
|
||||
///
|
||||
/// A simple skill name has no path separators, such as:
|
||||
/// - `code-review`
|
||||
/// - `deploy`
|
||||
///
|
||||
/// Full paths are resolved directly, while simple names are searched across
|
||||
/// skill directories in precedence order (`.agents/skills/`, `.warp-core/skills/`, `.claude/skills/`, `.codex/skills/`).
|
||||
///
|
||||
/// Uses cross-platform path semantics via [`std::path::Path`].
|
||||
pub fn is_full_path(&self) -> bool {
|
||||
let path = Path::new(&self.skill_identifier);
|
||||
// A path with multiple components (e.g., "foo/bar" or "foo\\bar") is a full path.
|
||||
// A single component (e.g., "code-review") is just a name.
|
||||
path.components().count() > 1
|
||||
}
|
||||
|
||||
/// Extracts the displayable skill name from this spec.
|
||||
///
|
||||
/// # Returns
|
||||
/// - For path-style identifiers (e.g., `.agents/skills/slack-triage/SKILL.md`): returns the parent directory name
|
||||
/// - For simple names: returns the name as-is
|
||||
/// - For invalid paths: falls back to file stem or the identifier itself
|
||||
pub fn skill_name(&self) -> String {
|
||||
let skill_identifier = self.skill_identifier.trim();
|
||||
let path = Path::new(skill_identifier);
|
||||
|
||||
if path.components().count() > 1 {
|
||||
if let Some(skill_name) = path
|
||||
.parent()
|
||||
.and_then(Path::file_name)
|
||||
.and_then(|name| name.to_str())
|
||||
{
|
||||
return skill_name.to_string();
|
||||
}
|
||||
|
||||
if let Some(file_stem) = path.file_stem().and_then(|stem| stem.to_str()) {
|
||||
return file_stem.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
skill_identifier.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SkillSpec {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return Err("Skill specifier cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Check for [qualifier:]skill_identifier format
|
||||
if let Some((qualifier, skill_identifier)) = s.split_once(':') {
|
||||
let qualifier = qualifier.trim();
|
||||
let skill_identifier = skill_identifier.trim();
|
||||
|
||||
if qualifier.is_empty() {
|
||||
return Err(
|
||||
"Qualifier cannot be empty in 'repo:skill_identifier' format".to_string(),
|
||||
);
|
||||
}
|
||||
if skill_identifier.is_empty() {
|
||||
return Err("Skill identifier cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Check for org/repo format in qualifier
|
||||
if let Some((org, repo)) = qualifier.split_once('/') {
|
||||
let org = org.trim();
|
||||
let repo = repo.trim();
|
||||
|
||||
if org.is_empty() {
|
||||
return Err("Organization cannot be empty".to_string());
|
||||
}
|
||||
if repo.is_empty() {
|
||||
return Err("Repository name cannot be empty".to_string());
|
||||
}
|
||||
|
||||
Ok(Self::with_org_and_repo(
|
||||
org.to_string(),
|
||||
repo.to_string(),
|
||||
skill_identifier.to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(Self::with_repo(
|
||||
qualifier.to_string(),
|
||||
skill_identifier.to_string(),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Ok(Self::without_repo(s.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SkillSpec {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match (&self.org, &self.repo) {
|
||||
(Some(org), Some(repo)) => write!(f, "{}/{}:{}", org, repo, self.skill_identifier),
|
||||
(None, Some(repo)) => write!(f, "{}:{}", repo, self.skill_identifier),
|
||||
_ => write!(f, "{}", self.skill_identifier),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "skill_tests.rs"]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user