use super::registry::Tool;
use super::schema::{ParamType, ToolParameter, ToolSchema};
use crate::error::Result;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::time::Duration;
const BUILTIN_SKILLS: &[(&str, &str)] = &[
(
"skill_creator",
include_str!("builtin_skills/skill_creator.md"),
),
("book_writer", include_str!("builtin_skills/book_writer.md")),
("agent_cron", include_str!("builtin_skills/agent_cron.md")),
];
pub struct SkillInfo {
pub name: String,
pub description: String,
pub full_md: String,
}
fn skills_dirs(project_path: &Path) -> Vec<PathBuf> {
vec![
project_path.join("skills"),
project_path.join(".aacode").join("skills"),
]
}
pub fn discover_skills(
project_path: &Path,
user_dir: Option<&Path>,
extra_builtins: &[String],
vfs_skills_dir: Option<&str>,
) -> Vec<SkillInfo> {
let mut map: BTreeMap<String, SkillInfo> = BTreeMap::new();
match user_dir {
Some(dir) => {
let dir_str = dir.to_string_lossy();
let prompt_dir = vfs_skills_dir.unwrap_or(&dir_str);
for (name, body) in BUILTIN_SKILLS {
if *name != "skill_creator"
&& *name != "book_writer"
&& !extra_builtins.iter().any(|x| x == *name)
{
continue;
}
let content = body.replace("{SKILLS_DIR}", prompt_dir);
map.insert(
(*name).to_string(),
SkillInfo {
name: (*name).to_string(),
description: first_description(&content),
full_md: content,
},
);
}
scan_dir_into(dir, &mut map);
}
None => {
for dir in skills_dirs(project_path) {
scan_dir_into(&dir, &mut map);
}
}
}
map.into_values().collect()
}
fn scan_dir_into(dir: &Path, map: &mut BTreeMap<String, SkillInfo>) {
let rd = match std::fs::read_dir(dir) {
Ok(r) => r,
Err(_) => return,
};
for e in rd.flatten() {
if !e.path().is_dir() {
continue;
}
if let Some(md_path) = find_skill_md(&e.path()) {
if let Ok(content) = std::fs::read_to_string(&md_path) {
let name = e.file_name().to_string_lossy().to_string();
map.insert(
name.clone(),
SkillInfo {
name,
description: first_description(&content),
full_md: content,
},
);
}
}
}
}
fn find_skill_md(dir: &Path) -> Option<PathBuf> {
for cand in ["SKILL.md", "skill.md"] {
let p = dir.join(cand);
if p.exists() {
return Some(p);
}
}
None
}
fn first_description(md: &str) -> String {
let lower = md.to_lowercase();
if let Some(pos) = lower.find("## description") {
let after = &md[pos..];
for line in after.lines().skip(1) {
let t = line.trim();
if !t.is_empty() && !t.starts_with('#') {
return t.chars().take(120).collect();
}
}
}
for line in md.lines() {
let t = line.trim();
if !t.is_empty() && !t.starts_with('#') {
return t.chars().take(120).collect();
}
}
String::new()
}
pub fn skills_list_for_prompt(
project_path: &Path,
user_dir: Option<&Path>,
extra_builtins: &[String],
vfs_skills_dir: Option<&str>,
) -> String {
let skills = discover_skills(project_path, user_dir, extra_builtins, vfs_skills_dir);
if skills.is_empty() {
return "(no skills installed)".to_string();
}
skills
.iter()
.map(|s| format!(" - {}: {}", s.name, s.description))
.collect::<Vec<_>>()
.join("\n")
}
pub struct RunSkillsTool {
pub project_path: PathBuf,
pub user_dir: Option<PathBuf>,
pub vfs_skills_dir: Option<String>,
pub extra_builtins: Vec<String>,
}
#[async_trait::async_trait]
impl Tool for RunSkillsTool {
fn schema(&self) -> ToolSchema {
ToolSchema::new(
"run_skills",
"Skills entry (three modes): run_skills(\"__list__\") lists skills; run_skills(\"__info__\", {\"skill_name\":\"x\"}) shows details; run_skills(\"x\", {...}) returns the skill's instruction guide to follow with run_shell.",
vec![
ToolParameter::new("skill_name", ParamType::String, false, "Skill name or __list__/__info__", &["skill", "name"]),
ToolParameter::new("params", ParamType::Object, false, "Skill params (may include skill_name for __info__)", &["arguments", "args", "kwargs"]),
ToolParameter::new(
"timeout",
ParamType::Integer,
false,
"Max seconds for skill discovery. Default: 5.",
&["time_limit", "max_time"],
),
],
)
}
async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
let name = args
.get("skill_name")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let params = args.get("params").cloned().unwrap_or(json!({}));
let name = name.or_else(|| {
params
.get("skill_name")
.or_else(|| params.get("name"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
let timeout = args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(5);
let pp = self.project_path.clone();
let ud = self.user_dir.clone();
let eb = self.extra_builtins.clone();
let vsd = self.vfs_skills_dir.clone();
let skills = match tokio::time::timeout(
Duration::from_secs(timeout),
tokio::task::spawn_blocking(move || {
discover_skills(&pp, ud.as_deref(), &eb, vsd.as_deref())
}),
)
.await
{
Ok(Ok(s)) => s,
_ => return Ok("Error: skill discovery timed out".to_string()),
};
match name.as_deref() {
None | Some("__list__") | Some("list") | Some("--list") | Some("ls") => {
if skills.is_empty() {
return Ok("No skills available.".to_string());
}
let mut out = String::from("Available skills:\n");
for s in &skills {
out.push_str(&format!("- {}: {}\n", s.name, s.description));
}
Ok(out)
}
Some("__info__") | Some("info") | Some("--info") => {
let target = params
.get("skill_name")
.or_else(|| params.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("");
if target.is_empty() {
return Ok("Error: skill_name required for __info__ mode".to_string());
}
match skills.iter().find(|s| s.name == target) {
Some(s) => Ok(s.full_md.clone()),
None => Ok(format!("Error: skill '{target}' not found")),
}
}
Some(sname) => match skills.iter().find(|s| s.name == sname) {
Some(s) => Ok(format!(
"Skill '{sname}' is a document skill. Follow these instructions using run_shell/other tools:\n\n{}",
s.full_md
)),
None => Ok(format!("Error: skill '{sname}' not found")),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn setup_skill(project: &Path, name: &str, body: &str) {
let dir = project.join("skills").join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("SKILL.md"), body).unwrap();
}
fn setup_skill_at(root: &Path, name: &str, body: &str) {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("SKILL.md"), body).unwrap();
}
fn tmp() -> PathBuf {
let d = std::env::temp_dir().join(format!(
"aacode_skills_{}_{}",
std::process::id(),
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(&d).unwrap();
d
}
fn tool(project: PathBuf) -> RunSkillsTool {
RunSkillsTool {
project_path: project,
user_dir: None,
vfs_skills_dir: None,
extra_builtins: vec![],
}
}
#[tokio::test]
async fn discovers_and_lists() {
let d = tmp();
setup_skill(&d, "pandas", "## Description\nData analysis helper\n");
let t = tool(d.clone());
let cancel = AtomicBool::new(false);
let out = t
.call(&json!({"skill_name": "__list__"}), &cancel)
.await
.unwrap();
assert!(out.contains("pandas"));
assert!(out.contains("Data analysis helper"));
assert!(!out.contains("skill_creator"));
assert!(!out.contains("agent_cron"));
}
#[tokio::test]
async fn info_returns_full_md() {
let d = tmp();
setup_skill(
&d,
"numpy",
"## Description\nNumeric\n## Usage\nrun stuff\n",
);
let t = tool(d.clone());
let cancel = AtomicBool::new(false);
let out = t
.call(
&json!({"skill_name": "__info__", "params": {"skill_name": "numpy"}}),
&cancel,
)
.await
.unwrap();
assert!(out.contains("## Usage"));
}
#[tokio::test]
async fn execute_returns_guide() {
let d = tmp();
setup_skill(&d, "deploy", "## Description\nDeploy\nSteps: do X\n");
let t = tool(d.clone());
let cancel = AtomicBool::new(false);
let out = t
.call(&json!({"skill_name": "deploy"}), &cancel)
.await
.unwrap();
assert!(out.contains("document skill"));
assert!(out.contains("Steps: do X"));
}
#[tokio::test]
async fn missing_skill() {
let d = tmp();
let t = tool(d);
let cancel = AtomicBool::new(false);
let out = t
.call(&json!({"skill_name": "ghost"}), &cancel)
.await
.unwrap();
assert!(out.contains("not found"));
}
#[test]
fn list_for_prompt() {
let d = tmp();
setup_skill(&d, "s1", "## Description\nfirst\n");
let list = skills_list_for_prompt(&d, None, &[], None);
assert!(list.contains("s1"));
}
#[test]
fn legacy_dedup_project_dirs() {
let d = tmp();
setup_skill(&d, "dup", "## Description\nfrom skills dir\n");
let hidden = d.join(".aacode").join("skills").join("dup");
std::fs::create_dir_all(&hidden).unwrap();
std::fs::write(hidden.join("SKILL.md"), "## Description\nfrom aacode dir\n").unwrap();
let skills = discover_skills(&d, None, &[], None);
assert_eq!(skills.iter().filter(|s| s.name == "dup").count(), 1);
}
#[test]
fn user_dir_mode_includes_always_injected_and_user_skills() {
let project = tmp();
let user = tmp();
setup_skill_at(&user, "api_probe", "## Description\nProbe an API\n");
let skills = discover_skills(&project, Some(&user), &[], None);
let names: Vec<_> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"skill_creator"),
"skill_creator must always appear: {names:?}"
);
assert!(
names.contains(&"book_writer"),
"book_writer must always appear: {names:?}"
);
assert!(names.contains(&"api_probe"));
assert!(
!names.contains(&"agent_cron"),
"agent_cron must NOT appear without extra_builtins"
);
}
#[test]
fn extra_builtins_gates_agent_cron() {
let project = tmp();
let user = tmp();
let skills = discover_skills(&project, Some(&user), &["agent_cron".into()], None);
let names: Vec<_> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"agent_cron"));
}
#[test]
fn user_dir_placeholder_substituted() {
let project = tmp();
let user = tmp();
let skills = discover_skills(&project, Some(&user), &[], None);
let creator = skills.iter().find(|s| s.name == "skill_creator").unwrap();
assert!(!creator.full_md.contains("{SKILLS_DIR}"));
assert!(creator
.full_md
.contains(&user.to_string_lossy().to_string()));
}
#[test]
fn user_skill_overrides_builtin() {
let project = tmp();
let user = tmp();
setup_skill_at(&user, "skill_creator", "## Description\ncustom override\n");
let skills = discover_skills(&project, Some(&user), &[], None);
let creator = skills.iter().find(|s| s.name == "skill_creator").unwrap();
assert_eq!(creator.description, "custom override");
}
#[tokio::test]
async fn user_dir_mode_via_tool_and_prompt_with_agent_cron_gated() {
let project = tmp();
let user = tmp();
setup_skill_at(
&user,
"remote_box",
"## Description\nRemote sandbox\n## Remote Endpoint\nhttps://x\n## Secret\nabc\n",
);
let t = RunSkillsTool {
project_path: project.clone(),
user_dir: Some(user.clone()),
vfs_skills_dir: None,
extra_builtins: vec!["agent_cron".into()],
};
let cancel = AtomicBool::new(false);
let out = t
.call(&json!({"skill_name": "__list__"}), &cancel)
.await
.unwrap();
assert!(out.contains("remote_box"));
assert!(out.contains("agent_cron"));
assert!(!out.contains("https://x"));
assert!(!out.contains("abc"));
let info = t
.call(
&json!({"skill_name": "__info__", "params": {"skill_name": "remote_box"}}),
&cancel,
)
.await
.unwrap();
assert!(info.contains("## Remote Endpoint"));
assert!(info.contains("abc"));
let prompt = skills_list_for_prompt(&project, Some(&user), &["agent_cron".into()], None);
assert!(prompt.contains("remote_box"));
assert!(prompt.contains("agent_cron"));
assert!(!prompt.contains("abc"));
}
}