cortex-agent 0.5.0

Self-learning AI agent with persistent memory, tools, plugins, and a beautiful terminal UI
use std::path::Path;
use std::process::Command;

/// A skill discovered from an imported git repository.
#[derive(Debug, Clone)]
pub struct ImportedSkill {
    pub name: String,
    pub description: String,
    pub content: String,
    pub category: String,
}

/// Frontmatter parsed from a markdown file.
#[derive(serde::Deserialize, Default)]
struct SkillFrontmatter {
    #[serde(default)]
    name: String,
    #[serde(default)]
    description: String,
    #[serde(default)]
    category: String,
}

/// Extract user/repo from various git URL formats.
/// Supports:
///   git@github.com:user/repo.git
///   https://github.com/user/repo.git
///   https://github.com/user/repo
fn parse_git_url(url: &str) -> Result<(String, String), String> {
    let url = url.trim();

    if url.starts_with("git@") && url.contains("github.com:") {
        // git@github.com:user/repo.git
        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") {
        // https://github.com/user/repo.git  or  https://github.com/user/repo
        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
        ))
    }
}

/// Parse YAML frontmatter from a markdown string.
/// Returns (frontmatter_map, body_content).
/// Frontmatter is delimited by `---` on the first two lines.
fn parse_frontmatter(content: &str) -> (SkillFrontmatter, String) {
    let content = content.trim();
    if content.starts_with("---") {
        // Find the closing ---
        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())
}

/// Sanitize a string for use as a skill name.
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;
            }
        }
        // other chars are dropped
    }
    result.trim_matches('-').to_lowercase()
}

/// Discover `.md` files recursively under a directory, parse frontmatter,
/// and return a list of ImportedSkill.
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() {
            // Skip hidden and .git directories
            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);

        // Derive name from frontmatter or filename
        let has_fm_name = !fm.name.is_empty();
        let name = if has_fm_name {
            sanitize_name(&fm.name)
        } else {
            // Use filename without .md extension
            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;
        }

        // Derive description from frontmatter or first non-empty line of body
        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))
        };

        // Derive category from parent directory name or frontmatter
        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()
        };

        // Use full body as skill content
        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
}

/// Clone a git repo to a temp directory and return the path.
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(())
}

/// Import skills from a git repository URL.
///
/// Clones the repo, discovers all `.md` skill files, parses frontmatter,
/// and returns a list of importable skills.
///
/// Supports:
///   - `git@github.com:user/repo.git` (SSH)
///   - `https://github.com/user/repo` (HTTPS)
///   - `https://github.com/user/repo.git`
pub fn import_skills_from_git(url: &str) -> Result<Vec<ImportedSkill>, String> {
    // Parse URL to validate
    let (user, repo) = parse_git_url(url)?;

    // Create a temp directory for cloning
    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)?;

    // Discover skills in the cloned repo
    let skills = discover_skills(&tmp_base);

    // Clean up
    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");
    }
}