diff --git a/cli/src/skills.rs b/cli/src/skills.rs index 01aa6c2..1d07513 100644 --- a/cli/src/skills.rs +++ b/cli/src/skills.rs @@ -12,36 +12,35 @@ struct SkillInfo { dir: PathBuf, } -/// Locate the `skills/` directory bundled with the installation. +/// Skill content is split across two directories: +/// - `skills/` — the bootstrap skill (discoverable by npx skills add) +/// - `skill-data/` — specialized skills (only served by the CLI) +/// +/// 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 -/// 2. ../skills/ relative to the executable (npm installs: binary is in bin/) +/// 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_skills_dir() -> Option { - if let Ok(dir) = env::var("AGENT_BROWSER_SKILLS_DIR") { - let p = PathBuf::from(dir); - if p.is_dir() { - return Some(p); - } - } - +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-* -> ../skills/ - let candidate = parent.join("..").join("skills"); - if candidate.is_dir() { - return Some(candidate); + // 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 { - let candidate = dir.join("skills"); - if candidate.is_dir() { - return Some(candidate); + if dir.join("skills").is_dir() { + return Some(dir.to_path_buf()); } match dir.parent() { Some(p) => dir = p, @@ -54,6 +53,27 @@ fn find_skills_dir() -> Option { None } +/// 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]; + } + } + + let Some(root) = find_package_root() else { + return vec![]; + }; + + SKILL_DIRS + .iter() + .map(|d| root.join(d)) + .filter(|p| p.is_dir()) + .collect() +} + /// Parse YAML frontmatter from a SKILL.md file. Returns (name, description). fn parse_frontmatter(content: &str) -> Option<(String, String)> { let content = content.trim_start(); @@ -91,33 +111,36 @@ fn parse_frontmatter(content: &str) -> Option<(String, String)> { Some((name?, description.unwrap_or_default())) } -/// Discover all skills in the skills directory. -fn discover_skills(skills_dir: &Path) -> Vec { +/// Discover all skills across the given directories. +fn discover_skills(dirs: &[PathBuf]) -> Vec { let mut skills = Vec::new(); - let entries = match fs::read_dir(skills_dir) { - Ok(e) => e, - Err(_) => return skills, - }; - 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, + for skills_dir in dirs { + let entries = match fs::read_dir(skills_dir) { + Ok(e) => e, Err(_) => continue, }; - if let Some((name, description)) = parse_frontmatter(&content) { - skills.push(SkillInfo { - name, - description, - dir: path, - }); + + 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)) = parse_frontmatter(&content) { + skills.push(SkillInfo { + name, + description, + dir: path, + }); + } } } @@ -174,8 +197,8 @@ fn collect_supplementary_files(skill_dir: &Path) -> Vec<(String, String)> { files } -fn run_list(skills_dir: &Path, json_mode: bool) { - let skills = discover_skills(skills_dir); +fn run_list(skills_dirs: &[PathBuf], json_mode: bool) { + let skills = discover_skills(skills_dirs); if skills.is_empty() { if json_mode { println!( @@ -215,8 +238,8 @@ fn run_list(skills_dir: &Path, json_mode: bool) { } } -fn run_get(skills_dir: &Path, names: &[String], get_all: bool, full: bool, json_mode: bool) { - let all_skills = discover_skills(skills_dir); +fn run_get(skills_dirs: &[PathBuf], names: &[String], get_all: bool, full: bool, json_mode: bool) { + let all_skills = discover_skills(skills_dirs); let targets: Vec<&SkillInfo> = if get_all { all_skills.iter().collect() @@ -325,10 +348,10 @@ fn run_get(skills_dir: &Path, names: &[String], get_all: bool, full: bool, json_ } } -fn run_path(skills_dir: &Path, name: Option<&str>, json_mode: bool) { +fn run_path(skills_dirs: &[PathBuf], name: Option<&str>, json_mode: bool) { match name { Some(name) => { - let all_skills = discover_skills(skills_dir); + 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(); @@ -363,50 +386,53 @@ fn run_path(skills_dir: &Path, name: Option<&str>, json_mode: bool) { } } None => { - let path = skills_dir.to_string_lossy().to_string(); + 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": { "path": path }, + "data": { "paths": paths }, })) .unwrap_or_default() ); } else { - println!("{}", path); + for p in &paths { + println!("{}", p); + } } } } } pub fn run_skills(args: &[String], json_mode: bool) { - let skills_dir = match find_skills_dir() { - Some(d) => d.canonicalize().unwrap_or(d), - None => { - 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 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_dir, json_mode), + None | Some("list") => run_list(&skills_dirs, json_mode), Some("get") => { let names: Vec = args[2..] .iter() @@ -415,11 +441,11 @@ pub fn run_skills(args: &[String], json_mode: bool) { .collect(); let full = args[2..].iter().any(|a| a == "--full"); let get_all = args[2..].iter().any(|a| a == "--all"); - run_get(&skills_dir, &names, get_all, full, json_mode); + 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_dir, name, json_mode); + run_path(&skills_dirs, name, json_mode); } Some(unknown) => { if json_mode { @@ -491,7 +517,7 @@ mod tests { } #[test] - fn test_discover_skills() { + 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"); @@ -500,12 +526,29 @@ mod tests { 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 skills = discover_skills(tmp.path()); + 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"); diff --git a/package.json b/package.json index e031116..2c8a37c 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "files": [ "bin", "scripts", + "skill-data", "skills" ], "bin": { diff --git a/skills/agentcore/SKILL.md b/skill-data/agentcore/SKILL.md similarity index 99% rename from skills/agentcore/SKILL.md rename to skill-data/agentcore/SKILL.md index fd6e6e0..421f695 100644 --- a/skills/agentcore/SKILL.md +++ b/skill-data/agentcore/SKILL.md @@ -1,8 +1,6 @@ --- name: agentcore description: Run agent-browser on AWS Bedrock AgentCore cloud browsers. Use when the user wants to use AgentCore, run browser automation on AWS, use a cloud browser with AWS credentials, or needs a managed browser session backed by AWS infrastructure. Triggers include "use agentcore", "run on AWS", "cloud browser with AWS", "bedrock browser", "agentcore session", or any task requiring AWS-hosted browser automation. -metadata: - internal: true allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*) --- diff --git a/skills/dogfood/SKILL.md b/skill-data/dogfood/SKILL.md similarity index 99% rename from skills/dogfood/SKILL.md rename to skill-data/dogfood/SKILL.md index b6a2ac2..dcd7d4d 100644 --- a/skills/dogfood/SKILL.md +++ b/skill-data/dogfood/SKILL.md @@ -1,8 +1,6 @@ --- name: dogfood description: Systematically explore and test a web application to find bugs, UX issues, and other problems. Use when asked to "dogfood", "QA", "exploratory test", "find issues", "bug hunt", "test this app/site/platform", or review the quality of a web application. Produces a structured report with full reproduction evidence -- step-by-step screenshots, repro videos, and detailed repro steps for every issue -- so findings can be handed directly to the responsible teams. -metadata: - internal: true allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*) --- diff --git a/skills/dogfood/references/issue-taxonomy.md b/skill-data/dogfood/references/issue-taxonomy.md similarity index 100% rename from skills/dogfood/references/issue-taxonomy.md rename to skill-data/dogfood/references/issue-taxonomy.md diff --git a/skills/dogfood/templates/dogfood-report-template.md b/skill-data/dogfood/templates/dogfood-report-template.md similarity index 100% rename from skills/dogfood/templates/dogfood-report-template.md rename to skill-data/dogfood/templates/dogfood-report-template.md diff --git a/skills/electron/SKILL.md b/skill-data/electron/SKILL.md similarity index 99% rename from skills/electron/SKILL.md rename to skill-data/electron/SKILL.md index 097e3e1..e4bf00e 100644 --- a/skills/electron/SKILL.md +++ b/skill-data/electron/SKILL.md @@ -1,8 +1,6 @@ --- name: electron description: Automate Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify, etc.) using agent-browser via Chrome DevTools Protocol. Use when the user needs to interact with an Electron app, automate a desktop app, connect to a running app, control a native app, or test an Electron application. Triggers include "automate Slack app", "control VS Code", "interact with Discord app", "test this Electron app", "connect to desktop app", or any task requiring automation of a native Electron application. -metadata: - internal: true allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*) --- diff --git a/skills/slack/SKILL.md b/skill-data/slack/SKILL.md similarity index 99% rename from skills/slack/SKILL.md rename to skill-data/slack/SKILL.md index 95dd744..f64eeb4 100644 --- a/skills/slack/SKILL.md +++ b/skill-data/slack/SKILL.md @@ -1,8 +1,6 @@ --- name: slack description: Interact with Slack workspaces using browser automation. Use when the user needs to check unread channels, navigate Slack, send messages, extract data, find information, search conversations, or automate any Slack task. Triggers include "check my Slack", "what channels have unreads", "send a message to", "search Slack for", "extract from Slack", "find who said", or any task requiring programmatic Slack interaction. -metadata: - internal: true allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*) --- diff --git a/skills/slack/references/slack-tasks.md b/skill-data/slack/references/slack-tasks.md similarity index 100% rename from skills/slack/references/slack-tasks.md rename to skill-data/slack/references/slack-tasks.md diff --git a/skills/slack/templates/slack-report-template.md b/skill-data/slack/templates/slack-report-template.md similarity index 100% rename from skills/slack/templates/slack-report-template.md rename to skill-data/slack/templates/slack-report-template.md diff --git a/skills/vercel-sandbox/SKILL.md b/skill-data/vercel-sandbox/SKILL.md similarity index 99% rename from skills/vercel-sandbox/SKILL.md rename to skill-data/vercel-sandbox/SKILL.md index 17ccd9d..3de43de 100644 --- a/skills/vercel-sandbox/SKILL.md +++ b/skill-data/vercel-sandbox/SKILL.md @@ -1,8 +1,6 @@ --- name: vercel-sandbox description: Run agent-browser + Chrome inside Vercel Sandbox microVMs for browser automation from any Vercel-deployed app. Use when the user needs browser automation in a Vercel app (Next.js, SvelteKit, Nuxt, Remix, Astro, etc.), wants to run headless Chrome without binary size limits, needs persistent browser sessions across commands, or wants ephemeral isolated browser environments. Triggers include "Vercel Sandbox browser", "microVM Chrome", "agent-browser in sandbox", "browser automation on Vercel", or any task requiring Chrome in a Vercel Sandbox. -metadata: - internal: true --- # Browser Automation with Vercel Sandbox