Files
galaxy/crates/galaxy_cli/src/skill.rs
T

193 lines
7.2 KiB
Rust

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;