use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub description: String,
pub dir: PathBuf,
}
pub fn skills_dir(data_dir: &Path) -> PathBuf {
data_dir.join("skills")
}
const FIND_SKILLS_SKILL: &str = include_str!("../assets/find-skills-SKILL.md");
const LEGACY_WEB_SEARCH_SKILL: &str = r"---
name: web-search
description: Search the web for current information and cite sources inline, Perplexity-style.
---
Call the `web_search` tool with a focused query. You may call it more than
once with refined queries if the first results are insufficient.
Each result comes back numbered `[1]`, `[2]`, ... with a title, URL, and
snippet. When you use a fact from a result, cite it inline immediately after
the sentence as `[n]` — do not bunch all citations at the end of a paragraph.
Finish your answer with a `Sources:` section listing every citation you used,
one per line, as `[n] title — url` (a bare URL, not a markdown link — this
terminal can't follow markdown link targets, only plain URLs are clickable).
Do not fabricate sources. If a claim isn't backed by a search result, don't
cite it.
";
pub fn install_builtin(dir: &Path) {
remove_legacy_web_search_skill(dir);
for (name, md) in [("find-skills", FIND_SKILLS_SKILL)] {
let path = dir.join(name).join("SKILL.md");
if path.exists() {
continue;
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, md);
}
}
fn remove_legacy_web_search_skill(dir: &Path) {
let skill_dir = dir.join("web-search");
let path = skill_dir.join("SKILL.md");
if std::fs::read_to_string(&path).ok().as_deref() == Some(LEGACY_WEB_SEARCH_SKILL) {
let _ = std::fs::remove_dir_all(skill_dir);
}
}
pub fn load_skills(dir: &Path) -> Vec<Skill> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut skills: Vec<Skill> = entries
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|e| {
let md = std::fs::read_to_string(e.path().join("SKILL.md")).ok()?;
let (name, description) = parse_frontmatter(&md)?;
Some(Skill {
name,
description,
dir: e.path(),
})
})
.collect();
skills.sort_by(|a, b| a.name.cmp(&b.name));
skills
}
pub fn parse_frontmatter(md: &str) -> Option<(String, String)> {
let md = md.trim_start_matches('\u{feff}'); let mut lines = md.lines();
if lines.next()?.trim() != "---" {
return None;
}
let mut name = None;
let mut description = None;
for line in lines {
let trimmed = line.trim();
if trimmed == "---" {
break;
}
if let Some((key, val)) = trimmed.split_once(':') {
let val = val.trim().trim_matches('"').trim_matches('\'').to_string();
match key.trim() {
"name" => name = Some(val),
"description" => description = Some(val),
_ => {}
}
}
}
Some((name?, description?))
}
pub fn skill_body(md: &str) -> &str {
let md = md.trim_start_matches('\u{feff}');
if !md.trim_start().starts_with("---") {
return md.trim();
}
let mut parts = md.splitn(3, "---");
parts.next(); parts.next(); parts.next().unwrap_or("").trim()
}
pub fn parse_gh_shorthand(s: &str) -> Option<(String, String, String)> {
let mut parts = s.trim().trim_matches('/').splitn(3, '/');
let owner = parts.next()?.to_string();
let repo = parts.next()?.to_string();
let path = parts.next().unwrap_or("").to_string();
if owner.is_empty() || repo.is_empty() {
return None;
}
Some((owner, repo, path))
}
#[derive(serde::Deserialize)]
struct GhEntry {
name: String,
#[serde(rename = "type")]
kind: String,
download_url: Option<String>,
}
pub async fn install_from_github(
client: &reqwest::Client,
owner: &str,
repo: &str,
path: &str,
dest_root: &Path,
) -> anyhow::Result<String> {
let entries = fetch_gh_contents(client, owner, repo, path).await?;
anyhow::ensure!(
entries
.iter()
.any(|e| e.name == "SKILL.md" && e.kind == "file"),
"no SKILL.md at {owner}/{repo}/{path}"
);
let name = path
.rsplit('/')
.next()
.filter(|s| !s.is_empty())
.unwrap_or(repo)
.to_string();
let dest = dest_root.join(&name);
let result = download_gh_entries(client, &entries, &dest, owner, repo, path, 1).await;
if let Err(e) = result {
let _ = std::fs::remove_dir_all(&dest);
return Err(e);
}
let md = std::fs::read_to_string(dest.join("SKILL.md"))?;
if parse_frontmatter(&md).is_none() {
let _ = std::fs::remove_dir_all(&dest);
anyhow::bail!("SKILL.md at {owner}/{repo}/{path} is missing name/description frontmatter");
}
Ok(name)
}
async fn fetch_gh_contents(
client: &reqwest::Client,
owner: &str,
repo: &str,
path: &str,
) -> anyhow::Result<Vec<GhEntry>> {
let url = format!("https://api.github.com/repos/{owner}/{repo}/contents/{path}");
let resp = client
.get(&url)
.header("User-Agent", "nexus-chat")
.send()
.await?
.error_for_status()?
.json::<Vec<GhEntry>>()
.await?;
Ok(resp)
}
fn download_gh_entries<'a>(
client: &'a reqwest::Client,
entries: &'a [GhEntry],
dest: &'a Path,
owner: &'a str,
repo: &'a str,
repo_path: &'a str,
depth_left: u8,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + 'a>> {
Box::pin(async move {
std::fs::create_dir_all(dest)?;
for entry in entries {
match entry.kind.as_str() {
"file" => {
let Some(url) = &entry.download_url else {
continue;
};
let bytes = client
.get(url)
.header("User-Agent", "nexus-chat")
.send()
.await?
.bytes()
.await?;
std::fs::write(dest.join(&entry.name), bytes)?;
}
"dir" if depth_left > 0 => {
let sub_repo_path = if repo_path.is_empty() {
entry.name.clone()
} else {
format!("{repo_path}/{}", entry.name)
};
let subentries = fetch_gh_contents(client, owner, repo, &sub_repo_path).await?;
download_gh_entries(
client,
&subentries,
&dest.join(&entry.name),
owner,
repo,
&sub_repo_path,
depth_left - 1,
)
.await?;
}
_ => {}
}
}
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_builtin_writes_find_skills_without_overwriting() {
let dir = std::env::temp_dir().join(format!("nexus-skills-{}", uuid::Uuid::new_v4()));
install_builtin(&dir);
let names: Vec<String> = load_skills(&dir).iter().map(|s| s.name.clone()).collect();
assert_eq!(names, vec!["find-skills"]);
let legacy = dir.join("web-search/SKILL.md");
std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
std::fs::write(&legacy, LEGACY_WEB_SEARCH_SKILL).unwrap();
install_builtin(&dir);
assert!(!legacy.exists());
std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
std::fs::write(
&legacy,
"---\nname: web-search\ndescription: mine\n---\ncustom",
)
.unwrap();
install_builtin(&dir);
assert!(legacy.exists());
std::fs::write(
dir.join("find-skills/SKILL.md"),
"---\nname: mine\ndescription: d\n---\nx",
)
.unwrap();
install_builtin(&dir);
assert!(
std::fs::read_to_string(dir.join("find-skills/SKILL.md"))
.unwrap()
.contains("mine")
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn parses_frontmatter_and_body() {
let md = "---\nname: web-search\ndescription: Search the web\n---\nBody text here.";
let (name, desc) = parse_frontmatter(md).unwrap();
assert_eq!(name, "web-search");
assert_eq!(desc, "Search the web");
assert_eq!(skill_body(md), "Body text here.");
}
#[test]
fn quoted_values_are_unquoted() {
let md = "---\nname: \"a\"\ndescription: 'b'\n---\nx";
assert_eq!(
parse_frontmatter(md),
Some(("a".to_string(), "b".to_string()))
);
}
#[test]
fn missing_frontmatter_is_none_but_body_is_whole_file() {
let md = "just a plain file, no frontmatter";
assert_eq!(parse_frontmatter(md), None);
assert_eq!(skill_body(md), md);
}
#[test]
fn missing_description_is_none() {
let md = "---\nname: x\n---\nbody";
assert_eq!(parse_frontmatter(md), None);
}
#[test]
fn crlf_line_endings_still_parse() {
let md = "---\r\nname: x\r\ndescription: y\r\n---\r\nbody\r\n";
assert_eq!(
parse_frontmatter(md),
Some(("x".to_string(), "y".to_string()))
);
}
#[test]
fn parses_owner_repo_path_shorthand() {
assert_eq!(
parse_gh_shorthand("anthropics/skills/pdf"),
Some((
"anthropics".to_string(),
"skills".to_string(),
"pdf".to_string()
))
);
}
#[test]
fn parses_bare_owner_repo_as_root_path() {
assert_eq!(
parse_gh_shorthand("anthropics/skills"),
Some((
"anthropics".to_string(),
"skills".to_string(),
String::new()
))
);
}
#[test]
fn rejects_shorthand_missing_a_segment() {
assert_eq!(parse_gh_shorthand("anthropics"), None);
assert_eq!(parse_gh_shorthand(""), None);
}
#[test]
fn multi_segment_path_survives_splitn() {
assert_eq!(
parse_gh_shorthand("a/b/nested/path/skill"),
Some((
"a".to_string(),
"b".to_string(),
"nested/path/skill".to_string()
))
);
}
#[test]
fn gh_contents_json_parses_into_entries() {
let json = r#"[
{"name": "SKILL.md", "type": "file", "download_url": "https://raw/SKILL.md"},
{"name": "helpers", "type": "dir", "download_url": null}
]"#;
let entries: Vec<GhEntry> = serde_json::from_str(json).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].name, "SKILL.md");
assert_eq!(entries[0].kind, "file");
assert_eq!(entries[1].kind, "dir");
}
}