Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ fn ensure_dashboard_dir() {

fn main() {
ensure_dashboard_dir();
println!("cargo:rerun-if-changed=../skill-data");

let protocol_dir = Path::new("cdp-protocol");
let out_dir = env::var("OUT_DIR").unwrap();
Expand Down
4 changes: 2 additions & 2 deletions cli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1940,14 +1940,14 @@ fn parity_tools() -> Vec<Value> {
tool(
TOOL_SKILLS_GET,
"Skills get",
"Get skill content, including home-directory skills when no package root is found. Package discovery looks for the bundled skill-data/core/SKILL.md file. Pass refs with one skill name to load only those references or templates, such as [\"commands\"].",
"Get skill content, including home-directory skills when no package root is found. Package discovery looks for the bundled skill-data/core/SKILL.md file. Without either, serves the skills compiled into the binary. Pass refs with one skill name to load only those references or templates, such as [\"commands\"].",
json!({ "names": string_array_schema("Skill names."), "all": { "type": "boolean" }, "full": { "type": "boolean" }, "refs": string_array_schema("References or templates to load from one skill, by path, file name, or stem (references/commands.md, commands.md, or commands). Returns only those files.") }),
&[],
),
tool(
TOOL_SKILLS_PATH,
"Skills path",
"Print skill directory path.",
"Print skill directory path, or report that skills are embedded in the binary.",
json!({ "name": { "type": "string" } }),
&[],
),
Expand Down
7 changes: 4 additions & 3 deletions cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4286,16 +4286,17 @@ Subcommands:
get <name> --full Include references and templates
get <name> --ref <ref> Output only one reference or template (repeatable)
get --all Output every skill
path [name] Print filesystem path to skill directory
path [name] Print filesystem path to skill directory, or
report that skills are embedded

Options:
--json Output as JSON

The skills command serves bundled skill content that always matches the
installed CLI version. Agents should use this to get current instructions
rather than relying on cached copies.
When no package root is found, the CLI reads manually installed skills from ~/.agent-browser/skills and ~/.agent-browser/skill-data.
Package discovery looks for the bundled skill-data/core/SKILL.md file.
Skills are read from AGENT_BROWSER_SKILLS_DIR, then the package root (found by its skill-data/core/SKILL.md file), then ~/.agent-browser/skills and ~/.agent-browser/skill-data.
When none exist, the CLI serves the skills compiled into the binary.

Examples:
agent-browser skills
Expand Down
208 changes: 159 additions & 49 deletions cli/src/skills.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use rust_embed::Embed;
use serde_json::json;
use std::env;
use std::fs;
Expand All @@ -9,7 +10,11 @@ use crate::color;
struct SkillInfo {
name: String,
description: String,
dir: PathBuf,
/// `None` when the skill is served from the copy compiled into the binary.
dir: Option<PathBuf>,
content: String,
/// Supplementary files as `(relative path, content)`, sorted by path.
files: Vec<(String, String)>,
/// When true, the skill is omitted from `skills list` and `skills get --all`
/// but can still be fetched by name via `skills get <name>`. Used for
/// bootstrap stubs that exist for external tooling (e.g. `npx skills add`)
Expand All @@ -29,6 +34,44 @@ struct SkillInfo {
/// Both are shipped in the npm package and searched by `discover_skills`.
const SKILL_DIRS: &[&str] = &["skills", "skill-data"];

const SUPPLEMENTARY_DIRS: &[&str] = &["references", "templates"];

/// `skill-data/` compiled into the binary, so standalone release binaries can
/// serve version-matched skills without any files on disk. Builds without the
/// directory (for example from the crates.io package) embed nothing.
#[derive(Embed)]
#[folder = "../skill-data/"]
#[allow_missing = true]
struct EmbeddedSkillData;

/// Skills on disk take precedence so `AGENT_BROWSER_SKILLS_DIR`, package
/// installs, and `~/.agent-browser` keep working; the embedded copy is the
/// fallback.
enum SkillSource {
Dirs(Vec<PathBuf>),
Embedded,
}

impl SkillSource {
fn find() -> Option<Self> {
let dirs = find_skills_dirs();
if !dirs.is_empty() {
Some(Self::Dirs(dirs))
} else if EmbeddedSkillData::iter().next().is_some() {
Some(Self::Embedded)
} else {
None
}
}

fn skills(&self) -> Vec<SkillInfo> {
match self {
Self::Dirs(dirs) => discover_skills(dirs),
Self::Embedded => discover_embedded_skills(),
}
}
}

/// Locate the package root that contains the skill directories.
///
/// Resolution order:
Expand Down Expand Up @@ -153,7 +196,9 @@ fn discover_skills(dirs: &[PathBuf]) -> Vec<SkillInfo> {
skills.push(SkillInfo {
name,
description,
dir: path,
files: collect_supplementary_files(&path),
dir: Some(path),
content,
hidden,
});
}
Expand All @@ -164,6 +209,47 @@ fn discover_skills(dirs: &[PathBuf]) -> Vec<SkillInfo> {
skills
}

/// Discover skills compiled into the binary, mirroring `discover_skills`.
fn discover_embedded_skills() -> Vec<SkillInfo> {
let embedded: Vec<(String, String)> = EmbeddedSkillData::iter()
.filter_map(|path| {
let data = EmbeddedSkillData::get(&path)?.data.into_owned();
Some((path.into_owned(), String::from_utf8(data).ok()?))
})
.collect();

let mut skills: Vec<SkillInfo> = embedded
.iter()
.filter_map(|(path, content)| {
let skill_dir = path
.strip_suffix("/SKILL.md")
.filter(|dir| !dir.contains('/'))?;
let (name, description, hidden) = parse_frontmatter(content)?;
let mut files: Vec<(String, String)> = embedded
.iter()
.filter_map(|(path, content)| {
let rel = path.strip_prefix(skill_dir)?.strip_prefix('/')?;
let (subdir, file_name) = rel.split_once('/')?;
(SUPPLEMENTARY_DIRS.contains(&subdir) && !file_name.contains('/'))
.then(|| (rel.to_string(), content.clone()))
})
.collect();
files.sort_by(|a, b| a.0.cmp(&b.0));
Some(SkillInfo {
name,
description,
dir: None,
content: content.clone(),
files,
hidden,
})
})
.collect();

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();
Expand All @@ -178,15 +264,10 @@ fn truncate_description(desc: &str, max_len: usize) -> String {
format!("{}...", &desc[..end])
}

/// Read the full SKILL.md content (including frontmatter).
fn read_skill_full(skill_md: &Path) -> Option<String> {
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"] {
for subdir_name in SUPPLEMENTARY_DIRS {
let subdir = skill_dir.join(subdir_name);
if !subdir.is_dir() {
continue;
Expand Down Expand Up @@ -257,10 +338,10 @@ fn print_error(json_mode: bool, message: &str) {
}

fn run_get_refs(skill: &SkillInfo, refs: &[String], json_mode: bool) {
let files = collect_supplementary_files(&skill.dir);
let files = &skill.files;
let mut selected = Vec::new();
for wanted in refs {
match resolve_ref(&files, wanted) {
match resolve_ref(files, wanted) {
Ok(file) => {
if !selected.contains(&file) {
selected.push(file);
Expand Down Expand Up @@ -314,11 +395,8 @@ fn run_get_refs(skill: &SkillInfo, refs: &[String], json_mode: bool) {
}
}

fn run_list(skills_dirs: &[PathBuf], json_mode: bool) {
let skills: Vec<SkillInfo> = discover_skills(skills_dirs)
.into_iter()
.filter(|s| !s.hidden)
.collect();
fn run_list(source: &SkillSource, json_mode: bool) {
let skills: Vec<SkillInfo> = source.skills().into_iter().filter(|s| !s.hidden).collect();
if skills.is_empty() {
if json_mode {
println!(
Expand Down Expand Up @@ -359,14 +437,14 @@ fn run_list(skills_dirs: &[PathBuf], json_mode: bool) {
}

fn run_get(
skills_dirs: &[PathBuf],
source: &SkillSource,
names: &[String],
get_all: bool,
full: bool,
refs: &[String],
json_mode: bool,
) {
let all_skills = discover_skills(skills_dirs);
let all_skills = source.skills();

let targets: Vec<&SkillInfo> = if get_all {
all_skills.iter().filter(|s| !s.hidden).collect()
Expand Down Expand Up @@ -438,21 +516,17 @@ fn run_get(
let items: Vec<serde_json::Value> = 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,
"content": s.content,
});
if full {
let supplementary = collect_supplementary_files(&s.dir);
if !supplementary.is_empty() {
let files: Vec<serde_json::Value> = supplementary
.iter()
.map(|(path, content)| json!({ "path": path, "content": content }))
.collect();
obj["files"] = json!(files);
}
if full && !s.files.is_empty() {
let files: Vec<serde_json::Value> = s
.files
.iter()
.map(|(path, content)| json!({ "path": path, "content": content }))
.collect();
obj["files"] = json!(files);
}
obj
})
Expand All @@ -466,16 +540,12 @@ fn run_get(
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!();
}
print!("{}", s.content);
if !s.content.ends_with('\n') {
println!();
}
if full {
let supplementary = collect_supplementary_files(&s.dir);
for (path, content) in &supplementary {
for (path, content) in &s.files {
println!("\n--- {} ---\n", path);
print!("{}", content);
if !content.ends_with('\n') {
Expand All @@ -487,19 +557,23 @@ fn run_get(
}
}

fn run_path(skills_dirs: &[PathBuf], name: Option<&str>, json_mode: bool) {
fn run_path(source: &SkillSource, name: Option<&str>, json_mode: bool) {
match name {
Some(name) => {
let all_skills = discover_skills(skills_dirs);
let all_skills = source.skills();
match all_skills.iter().find(|s| s.name == name) {
Some(s) => {
let path = s.dir.to_string_lossy().to_string();
Some(SkillInfo { dir: None, .. }) => print_embedded_location(
json!({ "name": name, "location": "embedded" }),
json_mode,
),
Some(SkillInfo { dir: Some(dir), .. }) => {
let path = dir.to_string_lossy().to_string();
if json_mode {
println!(
"{}",
serde_json::to_string(&json!({
"success": true,
"data": { "name": s.name, "path": path },
"data": { "name": name, "path": path },
}))
.unwrap_or_default()
);
Expand All @@ -525,7 +599,11 @@ fn run_path(skills_dirs: &[PathBuf], name: Option<&str>, json_mode: bool) {
}
}
None => {
let paths: Vec<String> = skills_dirs
let SkillSource::Dirs(dirs) = source else {
print_embedded_location(json!({ "location": "embedded", "paths": [] }), json_mode);
return;
};
let paths: Vec<String> = dirs
.iter()
.map(|d| d.to_string_lossy().to_string())
.collect();
Expand All @@ -547,9 +625,22 @@ fn run_path(skills_dirs: &[PathBuf], name: Option<&str>, json_mode: bool) {
}
}

/// `skills path` for embedded skills, which have no directory to print.
fn print_embedded_location(data: serde_json::Value, json_mode: bool) {
if json_mode {
println!(
"{}",
serde_json::to_string(&json!({ "success": true, "data": data })).unwrap_or_default()
);
} else {
println!(
"Skills are embedded in this binary. Run `agent-browser skills get <name>` to print one."
);
}
}

pub fn run_skills(args: &[String], json_mode: bool) {
let skills_dirs = find_skills_dirs();
if skills_dirs.is_empty() {
let Some(source) = SkillSource::find() else {
if json_mode {
println!(
"{}",
Expand All @@ -566,12 +657,12 @@ pub fn run_skills(args: &[String], json_mode: bool) {
);
}
exit(1);
}
};

let subcommand = args.get(1).map(|s| s.as_str());

match subcommand {
None | Some("list") => run_list(&skills_dirs, json_mode),
None | Some("list") => run_list(&source, json_mode),
Some("get") => {
let mut names = Vec::new();
let mut refs = Vec::new();
Expand All @@ -598,11 +689,11 @@ pub fn run_skills(args: &[String], json_mode: bool) {
},
}
}
run_get(&skills_dirs, &names, get_all, full, &refs, json_mode);
run_get(&source, &names, get_all, full, &refs, json_mode);
}
Some("path") => {
let name = args.get(2).map(|s| s.as_str());
run_path(&skills_dirs, name, json_mode);
run_path(&source, name, json_mode);
}
Some(unknown) => {
if json_mode {
Expand Down Expand Up @@ -828,4 +919,23 @@ mod tests {
}
}
}

#[test]
fn test_embedded_skills_match_skill_data() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../skill-data");
let on_disk = discover_skills(&[root]);
let embedded = discover_embedded_skills();
assert!(embedded
.iter()
.any(|s| s.name == "core" && !s.files.is_empty()));
assert_eq!(embedded.len(), on_disk.len());
for (embedded, on_disk) in embedded.iter().zip(&on_disk) {
assert_eq!(embedded.name, on_disk.name);
assert_eq!(embedded.description, on_disk.description);
assert_eq!(embedded.hidden, on_disk.hidden);
assert_eq!(embedded.content, on_disk.content, "{}", embedded.name);
assert_eq!(embedded.files, on_disk.files, "{}", embedded.name);
assert!(embedded.dir.is_none());
}
}
}
Loading