Move specialized skills to skill-data/ so npx skills add only finds one (#1227)

The skills CLI metadata.internal flag was never implemented (PRs #587
and #652 were both closed). All 6 skills were showing in the installer.

Move the 5 specialized skills (dogfood, electron, slack, vercel-sandbox,
agentcore) from skills/ to skill-data/, which the skills CLI does not
search. The bootstrap skill stays in skills/ for discovery. The Rust CLI
searches both directories so agent-browser skills list/get still serves
all 6.
This commit is contained in:
Chris Tate
2026-04-12 13:13:04 -05:00
committed by GitHub
parent 71343069d2
commit 7c2ff0a2a6
11 changed files with 119 additions and 85 deletions
+118 -75
View File
@@ -12,36 +12,35 @@ struct SkillInfo {
dir: PathBuf, 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: /// Resolution order:
/// 1. AGENT_BROWSER_SKILLS_DIR env var /// 1. AGENT_BROWSER_SKILLS_DIR env var (points directly at a single directory)
/// 2. ../skills/ relative to the executable (npm installs: binary is in bin/) /// 2. ../ relative to the executable (npm installs: binary is in bin/)
/// 3. Walk up from the executable to find a project root with skills/ /// 3. Walk up from the executable to find a project root with skills/
/// (dev builds where binary is in target/debug/ or target/release/) /// (dev builds where binary is in target/debug/ or target/release/)
fn find_skills_dir() -> Option<PathBuf> { fn find_package_root() -> Option<PathBuf> {
if let Ok(dir) = env::var("AGENT_BROWSER_SKILLS_DIR") {
let p = PathBuf::from(dir);
if p.is_dir() {
return Some(p);
}
}
if let Ok(exe) = env::current_exe() { if let Ok(exe) = env::current_exe() {
let exe = exe.canonicalize().unwrap_or(exe); let exe = exe.canonicalize().unwrap_or(exe);
if let Some(parent) = exe.parent() { if let Some(parent) = exe.parent() {
// npm install layout: bin/agent-browser-* -> ../skills/ // npm install layout: bin/agent-browser-* -> ../
let candidate = parent.join("..").join("skills"); let candidate = parent.join("..");
if candidate.is_dir() { if candidate.join("skills").is_dir() {
return Some(candidate); return Some(candidate.canonicalize().unwrap_or(candidate));
} }
// dev build layout: walk up from target/debug/ or target/release/ // dev build layout: walk up from target/debug/ or target/release/
let mut dir = parent; let mut dir = parent;
loop { loop {
let candidate = dir.join("skills"); if dir.join("skills").is_dir() {
if candidate.is_dir() { return Some(dir.to_path_buf());
return Some(candidate);
} }
match dir.parent() { match dir.parent() {
Some(p) => dir = p, Some(p) => dir = p,
@@ -54,6 +53,27 @@ fn find_skills_dir() -> Option<PathBuf> {
None None
} }
/// Collect all skill directories to search, respecting the env var override.
fn find_skills_dirs() -> Vec<PathBuf> {
// 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). /// Parse YAML frontmatter from a SKILL.md file. Returns (name, description).
fn parse_frontmatter(content: &str) -> Option<(String, String)> { fn parse_frontmatter(content: &str) -> Option<(String, String)> {
let content = content.trim_start(); let content = content.trim_start();
@@ -91,33 +111,36 @@ fn parse_frontmatter(content: &str) -> Option<(String, String)> {
Some((name?, description.unwrap_or_default())) Some((name?, description.unwrap_or_default()))
} }
/// Discover all skills in the skills directory. /// Discover all skills across the given directories.
fn discover_skills(skills_dir: &Path) -> Vec<SkillInfo> { fn discover_skills(dirs: &[PathBuf]) -> Vec<SkillInfo> {
let mut skills = Vec::new(); let mut skills = Vec::new();
let entries = match fs::read_dir(skills_dir) {
Ok(e) => e,
Err(_) => return skills,
};
for entry in entries.flatten() { for skills_dir in dirs {
let path = entry.path(); let entries = match fs::read_dir(skills_dir) {
if !path.is_dir() { Ok(e) => e,
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, Err(_) => continue,
}; };
if let Some((name, description)) = parse_frontmatter(&content) {
skills.push(SkillInfo { for entry in entries.flatten() {
name, let path = entry.path();
description, if !path.is_dir() {
dir: path, 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 files
} }
fn run_list(skills_dir: &Path, json_mode: bool) { fn run_list(skills_dirs: &[PathBuf], json_mode: bool) {
let skills = discover_skills(skills_dir); let skills = discover_skills(skills_dirs);
if skills.is_empty() { if skills.is_empty() {
if json_mode { if json_mode {
println!( 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) { fn run_get(skills_dirs: &[PathBuf], names: &[String], get_all: bool, full: bool, json_mode: bool) {
let all_skills = discover_skills(skills_dir); let all_skills = discover_skills(skills_dirs);
let targets: Vec<&SkillInfo> = if get_all { let targets: Vec<&SkillInfo> = if get_all {
all_skills.iter().collect() 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 { match name {
Some(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) { match all_skills.iter().find(|s| s.name == name) {
Some(s) => { Some(s) => {
let path = s.dir.to_string_lossy().to_string(); 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 => { None => {
let path = skills_dir.to_string_lossy().to_string(); let paths: Vec<String> = skills_dirs
.iter()
.map(|d| d.to_string_lossy().to_string())
.collect();
if json_mode { if json_mode {
println!( println!(
"{}", "{}",
serde_json::to_string(&json!({ serde_json::to_string(&json!({
"success": true, "success": true,
"data": { "path": path }, "data": { "paths": paths },
})) }))
.unwrap_or_default() .unwrap_or_default()
); );
} else { } else {
println!("{}", path); for p in &paths {
println!("{}", p);
}
} }
} }
} }
} }
pub fn run_skills(args: &[String], json_mode: bool) { pub fn run_skills(args: &[String], json_mode: bool) {
let skills_dir = match find_skills_dir() { let skills_dirs = find_skills_dirs();
Some(d) => d.canonicalize().unwrap_or(d), if skills_dirs.is_empty() {
None => { if json_mode {
if json_mode { println!(
println!( "{}",
"{}", serde_json::to_string(&json!({
serde_json::to_string(&json!({ "success": false,
"success": false, "error": "Skills directory not found. Set AGENT_BROWSER_SKILLS_DIR or reinstall via npm.",
"error": "Skills directory not found. Set AGENT_BROWSER_SKILLS_DIR or reinstall via npm.", }))
})) .unwrap_or_default()
.unwrap_or_default() );
); } else {
} else { eprintln!(
eprintln!( "{} Skills directory not found. Set AGENT_BROWSER_SKILLS_DIR or reinstall via npm.",
"{} Skills directory not found. Set AGENT_BROWSER_SKILLS_DIR or reinstall via npm.", color::error_indicator()
color::error_indicator() );
);
}
exit(1);
} }
}; exit(1);
}
let subcommand = args.get(1).map(|s| s.as_str()); let subcommand = args.get(1).map(|s| s.as_str());
match subcommand { match subcommand {
None | Some("list") => run_list(&skills_dir, json_mode), None | Some("list") => run_list(&skills_dirs, json_mode),
Some("get") => { Some("get") => {
let names: Vec<String> = args[2..] let names: Vec<String> = args[2..]
.iter() .iter()
@@ -415,11 +441,11 @@ pub fn run_skills(args: &[String], json_mode: bool) {
.collect(); .collect();
let full = args[2..].iter().any(|a| a == "--full"); let full = args[2..].iter().any(|a| a == "--full");
let get_all = args[2..].iter().any(|a| a == "--all"); 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") => { Some("path") => {
let name = args.get(2).map(|s| s.as_str()); 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) => { Some(unknown) => {
if json_mode { if json_mode {
@@ -491,7 +517,7 @@ mod tests {
} }
#[test] #[test]
fn test_discover_skills() { fn test_discover_skills_single_dir() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
create_test_skill(tmp.path(), "alpha", "Alpha skill"); create_test_skill(tmp.path(), "alpha", "Alpha skill");
create_test_skill(tmp.path(), "beta", "Beta 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::create_dir_all(tmp.path().join("not-a-skill")).unwrap();
fs::write(tmp.path().join("not-a-skill").join("README.md"), "hi").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.len(), 2);
assert_eq!(skills[0].name, "alpha"); assert_eq!(skills[0].name, "alpha");
assert_eq!(skills[1].name, "beta"); 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] #[test]
fn test_truncate_description() { fn test_truncate_description() {
assert_eq!(truncate_description("short", 10), "short"); assert_eq!(truncate_description("short", 10), "short");
+1
View File
@@ -6,6 +6,7 @@
"files": [ "files": [
"bin", "bin",
"scripts", "scripts",
"skill-data",
"skills" "skills"
], ],
"bin": { "bin": {
@@ -1,8 +1,6 @@
--- ---
name: agentcore 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. 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:*) allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*)
--- ---
@@ -1,8 +1,6 @@
--- ---
name: dogfood 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. 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:*) allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*)
--- ---
@@ -1,8 +1,6 @@
--- ---
name: electron 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. 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:*) allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*)
--- ---
@@ -1,8 +1,6 @@
--- ---
name: slack 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. 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:*) allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*)
--- ---
@@ -1,8 +1,6 @@
--- ---
name: vercel-sandbox 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. 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 # Browser Automation with Vercel Sandbox