use include_dir::{include_dir, Dir}; use serde_json::json; use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::process::exit; use crate::color; /// Skill content compiled into the binary so `skills get` works on a /// single-binary install (GitHub Release / install.sh), where there is no /// adjacent `skills/` or `skill-data/` on disk the way an npm install has. static EMBEDDED_SKILLS: Dir = include_dir!("$CARGO_MANIFEST_DIR/../skills"); static EMBEDDED_SKILL_DATA: Dir = include_dir!("$CARGO_MANIFEST_DIR/../skill-data"); struct SkillInfo { name: String, description: String, dir: PathBuf, /// When true, the skill is omitted from `skills list` and `skills get --all` /// but can still be fetched by name via `skills get `. Used for /// bootstrap stubs that exist for external tooling (e.g. `npx skills add`) /// but aren't the intended entry point for agents already inside the CLI. hidden: bool, } /// Skill content is split across two directories: /// /// - `skills/` — discovery stubs (picked up by `npx skills add`). Carry /// `hidden: true` so they don't show up in `skills list` or `skills get /// --all` inside the CLI, since they exist only to redirect external /// agents to `skills get core`. /// - `skill-data/` — runtime skill content served by the CLI (`core`, /// `electron`, `slack`, `dogfood`, etc.). /// /// Both are shipped in the npm package and searched by `discover_skills`. const SKILL_DIRS: &[&str] = &["skills", "skill-data"]; /// Locate the package root that contains the skill directories. /// /// Resolution order: /// 1. AGENT_BROWSER_SKILLS_DIR env var (points directly at a single directory) /// 2. ../ relative to the executable (npm installs: binary is in bin/) /// 3. Walk up from the executable to find a project root with skills/ /// (dev builds where binary is in target/debug/ or target/release/) fn find_package_root() -> Option { if let Ok(exe) = env::current_exe() { let exe = exe.canonicalize().unwrap_or(exe); if let Some(parent) = exe.parent() { // npm install layout: bin/agent-browser-* -> ../ let candidate = parent.join(".."); if candidate.join("skills").is_dir() { return Some(candidate.canonicalize().unwrap_or(candidate)); } // dev build layout: walk up from target/debug/ or target/release/ let mut dir = parent; loop { if dir.join("skills").is_dir() { return Some(dir.to_path_buf()); } match dir.parent() { Some(p) => dir = p, None => break, } } } } None } /// Extract the binary-embedded skill content to a per-version cache dir on /// first use, returning a package root that contains `skills/` and /// `skill-data/`. Fallback for single-binary installs (GitHub Release / /// install.sh) that have no on-disk skill directories. Version-stamped so an /// upgraded binary re-extracts fresh content. fn embedded_skills_root() -> Option { let base = dirs::cache_dir()? .join("agent-browser") .join(concat!("skills-", env!("CARGO_PKG_VERSION"))); let marker = base.join(".extracted"); if !marker.exists() { let _ = fs::create_dir_all(base.join("skills")); let _ = fs::create_dir_all(base.join("skill-data")); if EMBEDDED_SKILLS.extract(base.join("skills")).is_err() || EMBEDDED_SKILL_DATA .extract(base.join("skill-data")) .is_err() { return None; } let _ = fs::write(&marker, env!("CARGO_PKG_VERSION")); } base.join("skills").is_dir().then_some(base) } /// Collect all skill directories to search, respecting the env var override. fn find_skills_dirs() -> Vec { // Env var override: single directory, used as-is if let Ok(dir) = env::var("AGENT_BROWSER_SKILLS_DIR") { let p = PathBuf::from(dir); if p.is_dir() { return vec![p]; } } // On-disk package root (npm install layout, or dev build walking up to repo). if let Some(root) = find_package_root() { let dirs: Vec = SKILL_DIRS .iter() .map(|d| root.join(d)) .filter(|p| p.is_dir()) .collect(); if !dirs.is_empty() { return dirs; } } // Fallback: skill content compiled into the binary (single-binary install). if let Some(root) = embedded_skills_root() { return SKILL_DIRS .iter() .map(|d| root.join(d)) .filter(|p| p.is_dir()) .collect(); } vec![] } /// Parse YAML frontmatter from a SKILL.md file. Returns (name, description, hidden). fn parse_frontmatter(content: &str) -> Option<(String, String, bool)> { let content = content.trim_start(); if !content.starts_with("---") { return None; } let after_opening = &content[3..]; let end = after_opening.find("\n---")?; let frontmatter = &after_opening[..end]; let mut name = None; let mut description = None; let mut hidden = false; let lines: Vec<&str> = frontmatter.lines().collect(); let mut i = 0; while i < lines.len() { let line = lines[i]; if let Some(val) = line.strip_prefix("name:") { name = Some(val.trim().to_string()); } else if let Some(val) = line.strip_prefix("description:") { let mut desc = val.trim().to_string(); // Consume YAML continuation lines (indented with spaces or tab) while i + 1 < lines.len() && (lines[i + 1].starts_with(" ") || lines[i + 1].starts_with('\t')) { i += 1; desc.push(' '); desc.push_str(lines[i].trim()); } description = Some(desc); } else if let Some(val) = line.strip_prefix("hidden:") { hidden = matches!(val.trim(), "true" | "yes"); } i += 1; } Some((name?, description.unwrap_or_default(), hidden)) } /// Discover all skills across the given directories. fn discover_skills(dirs: &[PathBuf]) -> Vec { let mut skills = Vec::new(); for skills_dir in dirs { let entries = match fs::read_dir(skills_dir) { Ok(e) => e, Err(_) => continue, }; for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { continue; } let skill_md = path.join("SKILL.md"); if !skill_md.exists() { continue; } let content = match fs::read_to_string(&skill_md) { Ok(c) => c, Err(_) => continue, }; if let Some((name, description, hidden)) = parse_frontmatter(&content) { skills.push(SkillInfo { name, description, dir: path, hidden, }); } } } skills.sort_by(|a, b| a.name.cmp(&b.name)); skills } fn truncate_description(desc: &str, max_len: usize) -> String { if desc.len() <= max_len { return desc.to_string(); } let boundary = desc .char_indices() .take_while(|(i, _)| *i <= max_len) .last() .map(|(i, _)| i) .unwrap_or(max_len); let end = desc[..boundary].rfind(' ').unwrap_or(boundary); format!("{}...", &desc[..end]) } /// Read the full SKILL.md content (including frontmatter). fn read_skill_full(skill_md: &Path) -> Option { fs::read_to_string(skill_md).ok() } /// Collect all supplementary files (references/, templates/) for a skill. fn collect_supplementary_files(skill_dir: &Path) -> Vec<(String, String)> { let mut files = Vec::new(); for subdir_name in &["references", "templates"] { let subdir = skill_dir.join(subdir_name); if !subdir.is_dir() { continue; } let mut entries: Vec<_> = match fs::read_dir(&subdir) { Ok(e) => e.flatten().collect(), Err(_) => continue, }; entries.sort_by_key(|e| e.file_name()); for entry in entries { let path = entry.path(); if path.is_file() { if let Ok(content) = fs::read_to_string(&path) { let rel = format!( "{}/{}", subdir_name, path.file_name().unwrap_or_default().to_string_lossy() ); files.push((rel, content)); } } } } files } fn run_list(skills_dirs: &[PathBuf], json_mode: bool) { let skills: Vec = discover_skills(skills_dirs) .into_iter() .filter(|s| !s.hidden) .collect(); if skills.is_empty() { if json_mode { println!( "{}", serde_json::to_string(&json!({ "success": true, "data": [] })).unwrap_or_default() ); } else { println!("No skills found"); } return; } if json_mode { let items: Vec = skills .iter() .map(|s| { json!({ "name": s.name, "description": s.description, }) }) .collect(); println!( "{}", serde_json::to_string(&json!({ "success": true, "data": items })).unwrap_or_default() ); } else { let max_name = skills.iter().map(|s| s.name.len()).max().unwrap_or(0); for s in &skills { println!( " {: = if get_all { all_skills.iter().filter(|s| !s.hidden).collect() } else { let mut targets = Vec::new(); for name in names { if name.starts_with('-') { eprintln!( "{} Unknown flag ignored: {}", color::warning_indicator(), name ); continue; } match all_skills.iter().find(|s| s.name == *name) { Some(s) => targets.push(s), None => { if json_mode { println!( "{}", serde_json::to_string(&json!({ "success": false, "error": format!("Skill not found: {}", name), })) .unwrap_or_default() ); } else { eprintln!("{} Skill not found: {}", color::error_indicator(), name); } exit(1); } } } targets }; if targets.is_empty() { if json_mode { println!( "{}", serde_json::to_string(&json!({ "success": false, "error": "No skill name provided. Usage: agent-browser skills get ", })) .unwrap_or_default() ); } else { eprintln!( "{} No skill name provided. Usage: agent-browser skills get ", color::error_indicator() ); } exit(1); } if json_mode { let items: Vec = targets .iter() .map(|s| { let skill_md = s.dir.join("SKILL.md"); let content = read_skill_full(&skill_md).unwrap_or_default(); let mut obj = json!({ "name": s.name, "content": content, }); if full { let supplementary = collect_supplementary_files(&s.dir); if !supplementary.is_empty() { let files: Vec = supplementary .iter() .map(|(path, content)| json!({ "path": path, "content": content })) .collect(); obj["files"] = json!(files); } } obj }) .collect(); println!( "{}", serde_json::to_string(&json!({ "success": true, "data": items })).unwrap_or_default() ); } else { for (i, s) in targets.iter().enumerate() { if i > 0 { println!("\n---\n"); } let skill_md = s.dir.join("SKILL.md"); if let Some(content) = read_skill_full(&skill_md) { print!("{}", content); if !content.ends_with('\n') { println!(); } } if full { let supplementary = collect_supplementary_files(&s.dir); for (path, content) in &supplementary { println!("\n--- {} ---\n", path); print!("{}", content); if !content.ends_with('\n') { println!(); } } } } } } fn run_path(skills_dirs: &[PathBuf], name: Option<&str>, json_mode: bool) { match name { Some(name) => { let all_skills = discover_skills(skills_dirs); match all_skills.iter().find(|s| s.name == name) { Some(s) => { let path = s.dir.to_string_lossy().to_string(); if json_mode { println!( "{}", serde_json::to_string(&json!({ "success": true, "data": { "name": s.name, "path": path }, })) .unwrap_or_default() ); } else { println!("{}", path); } } None => { if json_mode { println!( "{}", serde_json::to_string(&json!({ "success": false, "error": format!("Skill not found: {}", name), })) .unwrap_or_default() ); } else { eprintln!("{} Skill not found: {}", color::error_indicator(), name); } exit(1); } } } None => { let paths: Vec = skills_dirs .iter() .map(|d| d.to_string_lossy().to_string()) .collect(); if json_mode { println!( "{}", serde_json::to_string(&json!({ "success": true, "data": { "paths": paths }, })) .unwrap_or_default() ); } else { for p in &paths { println!("{}", p); } } } } } pub fn run_skills(args: &[String], json_mode: bool) { let skills_dirs = find_skills_dirs(); if skills_dirs.is_empty() { if json_mode { println!( "{}", serde_json::to_string(&json!({ "success": false, "error": "Skills directory not found. Set AGENT_BROWSER_SKILLS_DIR or reinstall via npm.", })) .unwrap_or_default() ); } else { eprintln!( "{} Skills directory not found. Set AGENT_BROWSER_SKILLS_DIR or reinstall via npm.", color::error_indicator() ); } exit(1); } let subcommand = args.get(1).map(|s| s.as_str()); match subcommand { None | Some("list") => run_list(&skills_dirs, json_mode), Some("get") => { let names: Vec = args[2..] .iter() .filter(|a| *a != "--full" && *a != "--all") .cloned() .collect(); let full = args[2..].iter().any(|a| a == "--full"); let get_all = args[2..].iter().any(|a| a == "--all"); run_get(&skills_dirs, &names, get_all, full, json_mode); } Some("path") => { let name = args.get(2).map(|s| s.as_str()); run_path(&skills_dirs, name, json_mode); } Some(unknown) => { if json_mode { println!( "{}", serde_json::to_string(&json!({ "success": false, "error": format!("Unknown skills subcommand: {}", unknown), })) .unwrap_or_default() ); } else { eprintln!( "{} Unknown skills subcommand: {}", color::error_indicator(), unknown ); } exit(1); } } } #[cfg(test)] mod tests { use super::*; use std::fs; fn create_test_skill(dir: &Path, name: &str, description: &str) { let skill_dir = dir.join(name); fs::create_dir_all(&skill_dir).unwrap(); fs::write( skill_dir.join("SKILL.md"), format!( "---\nname: {}\ndescription: {}\n---\n\n# {}\n\nContent here.\n", name, description, name ), ) .unwrap(); } #[test] fn test_parse_frontmatter_basic() { let content = "---\nname: test-skill\ndescription: A test skill.\n---\n\n# Test\n"; let (name, desc, hidden) = parse_frontmatter(content).unwrap(); assert_eq!(name, "test-skill"); assert_eq!(desc, "A test skill."); assert!(!hidden); } #[test] fn test_parse_frontmatter_multiline_description() { let content = "---\nname: test\ndescription: First line\n continued here\n and here\n---\n"; let (name, desc, hidden) = parse_frontmatter(content).unwrap(); assert_eq!(name, "test"); assert_eq!(desc, "First line continued here and here"); assert!(!hidden); } #[test] fn test_parse_frontmatter_hidden_true() { let content = "---\nname: stub\ndescription: A bootstrap stub.\nhidden: true\n---\n"; let (name, desc, hidden) = parse_frontmatter(content).unwrap(); assert_eq!(name, "stub"); assert_eq!(desc, "A bootstrap stub."); assert!(hidden); } #[test] fn test_parse_frontmatter_hidden_false() { let content = "---\nname: visible\ndescription: Visible.\nhidden: false\n---\n"; let (_, _, hidden) = parse_frontmatter(content).unwrap(); assert!(!hidden); } #[test] fn test_parse_frontmatter_no_frontmatter() { let content = "# Just a heading\n\nNo frontmatter here.\n"; assert!(parse_frontmatter(content).is_none()); } #[test] fn test_parse_frontmatter_missing_name() { let content = "---\ndescription: No name field\n---\n"; assert!(parse_frontmatter(content).is_none()); } #[test] fn test_discover_skills_single_dir() { let tmp = tempfile::tempdir().unwrap(); create_test_skill(tmp.path(), "alpha", "Alpha skill"); create_test_skill(tmp.path(), "beta", "Beta skill"); // Non-skill directory (no SKILL.md) fs::create_dir_all(tmp.path().join("not-a-skill")).unwrap(); fs::write(tmp.path().join("not-a-skill").join("README.md"), "hi").unwrap(); let dirs = vec![tmp.path().to_path_buf()]; let skills = discover_skills(&dirs); assert_eq!(skills.len(), 2); assert_eq!(skills[0].name, "alpha"); assert_eq!(skills[1].name, "beta"); } #[test] fn test_discover_skills_multiple_dirs() { let tmp1 = tempfile::tempdir().unwrap(); let tmp2 = tempfile::tempdir().unwrap(); create_test_skill(tmp1.path(), "alpha", "Alpha skill"); create_test_skill(tmp2.path(), "beta", "Beta skill"); create_test_skill(tmp2.path(), "gamma", "Gamma skill"); let dirs = vec![tmp1.path().to_path_buf(), tmp2.path().to_path_buf()]; let skills = discover_skills(&dirs); assert_eq!(skills.len(), 3); assert_eq!(skills[0].name, "alpha"); assert_eq!(skills[1].name, "beta"); assert_eq!(skills[2].name, "gamma"); } #[test] fn test_truncate_description() { assert_eq!(truncate_description("short", 10), "short"); assert_eq!( truncate_description("this is a longer description that should be truncated", 20), "this is a longer..." ); } #[test] fn test_truncate_description_multibyte() { let desc = "Browse \u{00e9}l\u{00e9}ments and \u{65e5}\u{672c}\u{8a9e} pages quickly"; let result = truncate_description(desc, 20); assert!(result.ends_with("...")); assert!(result.len() <= 30); } #[test] fn test_collect_supplementary_files() { let tmp = tempfile::tempdir().unwrap(); let refs_dir = tmp.path().join("references"); fs::create_dir_all(&refs_dir).unwrap(); fs::write(refs_dir.join("auth.md"), "# Auth\n").unwrap(); fs::write(refs_dir.join("commands.md"), "# Commands\n").unwrap(); let templates_dir = tmp.path().join("templates"); fs::create_dir_all(&templates_dir).unwrap(); fs::write(templates_dir.join("example.sh"), "#!/bin/bash\n").unwrap(); let files = collect_supplementary_files(tmp.path()); assert_eq!(files.len(), 3); assert_eq!(files[0].0, "references/auth.md"); assert_eq!(files[1].0, "references/commands.md"); assert_eq!(files[2].0, "templates/example.sh"); } }