use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct ImportedSkill {
pub name: String,
pub description: String,
pub content: String,
pub category: String,
}
#[derive(serde::Deserialize, Default)]
struct SkillFrontmatter {
#[serde(default)]
name: String,
#[serde(default)]
description: String,
#[serde(default)]
category: String,
}
fn parse_git_url(url: &str) -> Result<(String, String), String> {
let url = url.trim();
if url.starts_with("git@") && url.contains("github.com:") {
let after_host = url
.splitn(2, ':')
.nth(1)
.unwrap_or("")
.trim_end_matches(".git")
.trim_end_matches('/');
let parts: Vec<&str> = after_host.splitn(2, '/').collect();
if parts.len() < 2 {
return Err(format!("Invalid SSH git URL: {}", url));
}
Ok((parts[0].to_string(), parts[1].to_string()))
} else if url.contains("github.com") {
let repo_part = url
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/")
.trim_end_matches(".git")
.trim_end_matches('/');
let parts: Vec<&str> = repo_part.splitn(2, '/').collect();
if parts.len() < 2 {
return Err(format!("Invalid GitHub URL: {}", url));
}
Ok((parts[0].to_string(), parts[1].to_string()))
} else {
Err(format!(
"Unsupported git URL format: {}. Use git@github.com:user/repo.git or https://github.com/user/repo",
url
))
}
}
fn parse_frontmatter(content: &str) -> (SkillFrontmatter, String) {
let content = content.trim();
if content.starts_with("---") {
if let Some(end) = content[3..].find("\n---") {
let yaml_str = &content[3..3 + end];
let body = content[3 + end + 4..].trim().to_string();
let fm: SkillFrontmatter = serde_yaml::from_str(yaml_str).unwrap_or_default();
return (fm, body);
}
}
(SkillFrontmatter::default(), content.to_string())
}
fn sanitize_name(name: &str) -> String {
let mut result = String::new();
let mut prev_hyphen = false;
for c in name.chars() {
if c.is_alphanumeric() || c == '-' || c == '_' {
result.push(c);
prev_hyphen = false;
} else if c.is_whitespace() || c == '.' || c == '/' || c == '\\' {
if !prev_hyphen {
result.push('-');
prev_hyphen = true;
}
}
}
result.trim_matches('-').to_lowercase()
}
fn discover_skills(repo_dir: &Path) -> Vec<ImportedSkill> {
let mut skills = Vec::new();
if !repo_dir.is_dir() {
return skills;
}
let entries = match std::fs::read_dir(repo_dir) {
Ok(e) => e,
Err(_) => return skills,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') {
continue;
}
}
skills.extend(discover_skills(&path));
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let (fm, body) = parse_frontmatter(&content);
let has_fm_name = !fm.name.is_empty();
let name = if has_fm_name {
sanitize_name(&fm.name)
} else {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("unnamed");
sanitize_name(stem)
};
if name.is_empty() {
continue;
}
if body.trim().is_empty() {
continue;
}
let has_fm_desc = !fm.description.is_empty();
let description = if has_fm_desc {
fm.description.clone()
} else {
body.lines()
.find(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
.map(|l| l.trim().to_string())
.unwrap_or_else(|| format!("{} skill", name))
};
let has_fm_cat = !fm.category.is_empty();
let category = if has_fm_cat {
fm.category.clone()
} else if let Some(parent_name) = path.parent().and_then(|p| p.file_name().and_then(|n| n.to_str())) {
if parent_name != "." && !parent_name.starts_with('.') {
sanitize_name(parent_name)
} else {
"imported".to_string()
}
} else {
"imported".to_string()
};
let skill_content = if has_fm_desc {
format!("# {}\n\n> {}\n\n{}", name, fm.description, body)
} else {
format!("# {}\n\n{}", name, body)
};
skills.push(ImportedSkill {
name,
description,
content: skill_content,
category,
});
}
skills
}
fn clone_repo(url: &str, tmp_dir: &Path) -> Result<(), String> {
let output = Command::new("git")
.args(["clone", "--depth", "1", url])
.arg(tmp_dir)
.output()
.map_err(|e| format!("Failed to run git: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git clone failed: {}", stderr.trim()));
}
Ok(())
}
pub fn import_skills_from_git(url: &str) -> Result<Vec<ImportedSkill>, String> {
let (user, repo) = parse_git_url(url)?;
let tmp_base = std::env::temp_dir().join(format!("cortex-skills-{}-{}", user, repo));
let _ = std::fs::remove_dir_all(&tmp_base);
clone_repo(url, &tmp_base)?;
let skills = discover_skills(&tmp_base);
let _ = std::fs::remove_dir_all(&tmp_base);
if skills.is_empty() {
return Err(format!(
"No markdown skill files found in '{}'. Expected .md files with optional YAML frontmatter.",
url
));
}
Ok(skills)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_git_url_ssh() {
let (user, repo) = parse_git_url("git@github.com:shafiqruet/ajbm-skills.git").unwrap();
assert_eq!(user, "shafiqruet");
assert_eq!(repo, "ajbm-skills");
}
#[test]
fn test_parse_git_url_https() {
let (user, repo) = parse_git_url("https://github.com/shafiqruet/ajbm-skills.git").unwrap();
assert_eq!(user, "shafiqruet");
assert_eq!(repo, "ajbm-skills");
}
#[test]
fn test_parse_git_url_https_no_suffix() {
let (user, repo) = parse_git_url("https://github.com/shafiqruet/ajbm-skills").unwrap();
assert_eq!(user, "shafiqruet");
assert_eq!(repo, "ajbm-skills");
}
#[test]
fn test_parse_frontmatter_basic() {
let content = "---\nname: interview\ndescription: Transform ideas into specs\n---\n\n# Interview Skill\n\nBody content here.";
let (fm, body) = parse_frontmatter(content);
assert_eq!(fm.name, "interview");
assert_eq!(fm.description, "Transform ideas into specs");
assert!(body.contains("Interview Skill"));
}
#[test]
fn test_parse_frontmatter_no_frontmatter() {
let content = "# Just a heading\n\nSome content";
let (fm, body) = parse_frontmatter(content);
assert!(fm.name.is_empty());
assert_eq!(body, content);
}
#[test]
fn test_sanitize_name() {
assert_eq!(sanitize_name("Interview Prep"), "interview-prep");
assert_eq!(sanitize_name("systematic-debugging"), "systematic-debugging");
assert_eq!(sanitize_name(" spaced out "), "spaced-out");
}
}