use anda_core::{
Agent, BoxError, FunctionDefinition, Resource, Tool, ToolOutput, select_resources,
};
use parking_lot::{Mutex, RwLock};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{
any::Any,
collections::BTreeMap,
ffi::OsStr,
path::{Path, PathBuf},
sync::Arc,
time::SystemTime,
};
use crate::{
context::{BaseCtx, SUB_AGENT_PREFIX},
extension::{
fs::{ensure_file_size_within_limit, ensure_regular_file, normalize_relative_path},
hooked_call, tool_definition,
},
hook::DynToolHook,
subagent::{SubAgent, SubAgentSet},
};
mod types;
pub use types::*;
const MAX_SKILL_FILE_BYTES: u64 = 512 * 1024;
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SkillArgs {
pub name: String,
}
pub type SkillToolHook = DynToolHook<SkillArgs, SkillContentOutput>;
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct SkillContentOutput {
pub name: String,
pub description: String,
pub execution: SkillExecution,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub callable: Option<String>,
pub base_dir: String,
pub path: String,
pub content: String,
}
pub type SkillFilter = Arc<dyn Fn(&Skill) -> bool + Send + Sync>;
struct ParsedSkillFile {
len: u64,
modified: Option<SystemTime>,
skill: Option<Skill>,
}
pub struct SkillManager {
default_skills_dir: PathBuf,
skills_dirs: Vec<PathBuf>,
skills: RwLock<BTreeMap<String, Skill>>,
subagents: RwLock<BTreeMap<String, SubAgent>>,
filter: RwLock<Option<SkillFilter>>,
parsed_files: Mutex<BTreeMap<PathBuf, ParsedSkillFile>>,
description: String,
default_skill_tools: Vec<String>,
}
static DEFAULT_SKILL_TOOLS: &[&str] = &[
"shell",
"read_file",
"search_file",
"write_file",
"edit_file",
"todo",
"tools_select",
SkillManager::NAME,
];
fn build_skills_dirs(
default_skills_dir: PathBuf,
additional_skills_dirs: Vec<PathBuf>,
) -> Vec<PathBuf> {
let mut skills_dirs = vec![default_skills_dir];
for dir in additional_skills_dirs {
if !skills_dirs.iter().any(|existing| existing == &dir) {
skills_dirs.push(dir);
}
}
skills_dirs
}
fn format_path_list(paths: &[PathBuf]) -> String {
paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
fn build_description(default_skills_dir: &Path, skills_dirs: &[PathBuf]) -> String {
format!(
"Read a skill's SKILL.md by name, following the Agent Skills specification. Agent Skills \
are folders of instructions, scripts, and resources. Most skills run inline: this tool \
returns the full SKILL.md and you follow it yourself in this conversation, reading the \
bundled files it references from `base_dir` as you need them. A skill that declares \
`execution: subagent` instead returns a `callable` name to delegate to, which runs it in \
an isolated worker that cannot see this conversation or ask the user anything. \
Skill directories: {}. Default skill creation directory: {}",
format_path_list(skills_dirs),
default_skills_dir.display()
)
}
impl SkillManager {
pub const NAME: &'static str = "skills_manager";
pub fn new(skills_dir: PathBuf) -> Self {
Self::new_with_dirs(skills_dir, Vec::new())
}
pub fn new_with_dirs(
default_skills_dir: PathBuf,
additional_skills_dirs: Vec<PathBuf>,
) -> Self {
let skills_dirs = build_skills_dirs(default_skills_dir.clone(), additional_skills_dirs);
Self {
skills: RwLock::new(BTreeMap::new()),
subagents: RwLock::new(BTreeMap::new()),
filter: RwLock::new(None),
parsed_files: Mutex::new(BTreeMap::new()),
description: build_description(&default_skills_dir, &skills_dirs),
default_skills_dir,
skills_dirs,
default_skill_tools: DEFAULT_SKILL_TOOLS.iter().map(|s| s.to_string()).collect(),
}
}
pub fn default_skills_dir(&self) -> &Path {
&self.default_skills_dir
}
pub fn skills_dirs(&self) -> &[PathBuf] {
&self.skills_dirs
}
pub fn with_description(mut self, description: String) -> Self {
self.description = description;
self
}
pub fn with_default_skill_tools(mut self, tools: Vec<String>) -> Self {
self.default_skill_tools = tools;
self
}
pub fn set_skill_filter(&self, filter: Option<SkillFilter>) {
let mut current = self.filter.write();
*current = filter;
let Some(filter) = current.as_ref() else {
return;
};
let mut subagents = self.subagents.write();
let mut skills = self.skills.write();
skills.retain(|name, skill| {
let admitted = filter(skill);
if !admitted {
subagents.remove(name);
}
admitted
});
}
fn admits(&self, skill: &Skill) -> bool {
match self.filter.read().as_ref() {
Some(filter) => filter(skill),
None => true,
}
}
fn materialize(&self, skill: &Skill) -> SubAgent {
let mut agent = SubAgent::from(skill);
if !skill.declares_tools() {
agent.tools = self.default_skill_tools.clone();
}
agent
}
async fn read_text_file(&self, path: &Path, max_size: u64) -> Result<String, BoxError> {
let meta = tokio::fs::symlink_metadata(path).await.map_err(|err| {
format!(
"Failed to inspect file metadata (path: {}): {err}",
path.display()
)
})?;
ensure_regular_file(&meta, path, "Reading multiply-linked files is not allowed")?;
ensure_file_size_within_limit(&meta, path, max_size)?;
let data = tokio::fs::read(path)
.await
.map_err(|err| format!("Failed to read file (path: {}): {err}", path.display()))?;
types::decode_skill_md_bytes(data).map_err(|_| {
format!(
"Only UTF-8 or supported text-encoded skill files are supported by skills_manager (path: {})",
path.display()
)
.into()
})
}
async fn parse_skill_file(&self, path: &Path, base_dir: &Path) -> Option<Skill> {
let meta = tokio::fs::symlink_metadata(path).await.ok()?;
let (len, modified) = (meta.len(), meta.modified().ok());
if let Some(cached) = self.parsed_files.lock().get(path)
&& cached.len == len
&& cached.modified == modified
{
return cached.skill.clone();
}
let skill = match self.read_text_file(path, MAX_SKILL_FILE_BYTES).await {
Ok(content) => parse_skill_md(base_dir.to_path_buf(), &content).ok(),
Err(_) => None,
};
self.parsed_files.lock().insert(
path.to_path_buf(),
ParsedSkillFile {
len,
modified,
skill: skill.clone(),
},
);
skill
}
fn skills_dir_rank(&self, base_dir: &Path) -> usize {
self.skills_dirs
.iter()
.position(|root| base_dir.starts_with(root))
.unwrap_or(self.skills_dirs.len())
}
async fn find_skill_dir(&self, name: &str) -> Result<Option<PathBuf>, BoxError> {
validate_skill_name(name)?;
let mut matches: Vec<(usize, PathBuf)> = Vec::new();
let push = |base_dir: PathBuf, matches: &mut Vec<(usize, PathBuf)>| {
if !matches.iter().any(|(_, path)| path == &base_dir) {
matches.push((self.skills_dir_rank(&base_dir), base_dir));
}
};
{
let skills = self.skills.read();
for skill in skills.values() {
let dir_name_matches = skill.base_dir.file_name() == Some(OsStr::new(name));
if skill.frontmatter.name == name || dir_name_matches {
push(skill.base_dir.clone(), &mut matches);
}
}
}
for skills_dir in &self.skills_dirs {
if skills_dir.is_dir() {
for path in find_skill_files(skills_dir).await? {
let Some(base_dir) = path.parent() else {
continue;
};
let base_dir = base_dir.to_path_buf();
let dir_name_matches = base_dir.file_name() == Some(OsStr::new(name));
let parsed = self.parse_skill_file(&path, &base_dir).await;
match parsed {
Some(skill) => {
if (skill.frontmatter.name == name || dir_name_matches)
&& self.admits(&skill)
{
push(base_dir, &mut matches);
}
}
None if dir_name_matches => push(base_dir, &mut matches),
None => {}
}
}
}
}
let Some(best) = matches.iter().map(|(rank, _)| *rank).min() else {
return Ok(None);
};
let mut winners = matches.into_iter().filter(|(rank, _)| *rank == best);
let winner = winners.next().map(|(_, path)| path);
if winners.next().is_some() {
return Err(format!(
"multiple skills named {:?} exist under configured skills directories: {}",
name,
format_path_list(&self.skills_dirs)
)
.into());
}
Ok(winner)
}
fn display_path(&self, path: &Path) -> String {
for skills_dir in &self.skills_dirs {
if let Ok(stripped) = path.strip_prefix(skills_dir) {
return normalize_relative_path(stripped);
}
}
if let Ok(canonical_path) = std::fs::canonicalize(path) {
for skills_dir in &self.skills_dirs {
if let Ok(root) = std::fs::canonicalize(skills_dir)
&& let Ok(stripped) = canonical_path.strip_prefix(&root)
{
return normalize_relative_path(stripped);
}
}
}
path.display().to_string()
}
async fn read_skill_action(&self, args: SkillArgs) -> Result<SkillContentOutput, BoxError> {
validate_skill_name(&args.name)?;
let skill_dir = self
.find_skill_dir(&args.name)
.await?
.ok_or_else(|| format!("skill {:?} not found", args.name))?;
let target = skill_dir.join("SKILL.md");
let content = self.read_text_file(&target, MAX_SKILL_FILE_BYTES).await?;
let skill = parse_skill_md(skill_dir, &content)?;
if !self.admits(&skill) {
return Err(format!("skill {:?} not found", args.name).into());
}
if skill.frontmatter.name != args.name {
return Err(format!(
"SKILL.md frontmatter name {:?} must match requested skill name {:?}",
skill.frontmatter.name, args.name
)
.into());
}
let callable = skill
.is_subagent()
.then(|| format!("{SUB_AGENT_PREFIX}{}", skill.agent_name));
let base_dir = skill.base_dir.display().to_string();
self.upsert_skill(skill.clone());
Ok(SkillContentOutput {
name: skill.frontmatter.name,
description: skill.frontmatter.description,
execution: skill.execution,
callable,
base_dir,
path: self.display_path(&target),
content,
})
}
pub async fn load(&self) -> Result<(), BoxError> {
let mut skills = BTreeMap::new();
let mut loaded_dirs = 0usize;
for skills_dir in &self.skills_dirs {
if !skills_dir.is_dir() {
log::error!(
"skills directory {} does not exist, skipping load",
skills_dir.display()
);
continue;
}
loaded_dirs += 1;
for (agent_name, skill) in load_skills_from_dir(skills_dir).await? {
if !self.admits(&skill) {
continue;
}
#[allow(clippy::map_entry)]
if skills.contains_key(&agent_name) {
log::warn!(
"duplicate skill name {} at {}, skipping",
agent_name,
skill.base_dir.join("SKILL.md").display()
);
} else {
skills.insert(agent_name, skill);
}
}
}
if loaded_dirs == 0 {
return Ok(());
}
log::info!(
"loaded {} skill(s) from {} configured skill directories: {}",
skills.len(),
loaded_dirs,
format_path_list(&self.skills_dirs)
);
self.replace_skills(skills);
Ok(())
}
fn replace_skills(&self, mut skills: BTreeMap<String, Skill>) {
let filter = self.filter.read();
if let Some(filter) = filter.as_ref() {
skills.retain(|_, skill| filter(skill));
}
let mut subagents = self.subagents.write();
let rebuilt = skills
.iter()
.filter(|(_, skill)| skill.is_subagent())
.map(|(name, skill)| {
let mut agent = self.materialize(skill);
if let Some(existing) = subagents.get(name) {
agent.subsessions = existing.subsessions.clone();
}
(name.clone(), agent)
})
.collect();
*subagents = rebuilt;
*self.skills.write() = skills;
}
fn upsert_skill(&self, skill: Skill) {
let filter = self.filter.read();
if let Some(filter) = filter.as_ref()
&& !filter(&skill)
{
return;
}
let name = skill.agent_name.clone();
let mut subagents = self.subagents.write();
if skill.is_subagent() {
let mut agent = self.materialize(&skill);
if let Some(existing) = subagents.get(&name) {
agent.subsessions = existing.subsessions.clone();
}
subagents.insert(name.clone(), agent);
} else {
subagents.remove(&name);
}
self.skills.write().insert(name, skill);
}
pub fn get_skill(&self, lowercase_name: &str) -> Option<Skill> {
self.skills.read().get(lowercase_name).cloned()
}
pub fn subagents(&self) -> Vec<SubAgent> {
self.subagents.read().values().cloned().collect::<Vec<_>>()
}
pub fn list(&self) -> BTreeMap<String, Skill> {
self.skills.read().clone()
}
fn skills_catalog(&self) -> String {
let skills = self.skills.read();
if skills.is_empty() {
return String::new();
}
let mut catalog = String::from("\nLoaded skills (name, execution, description):");
for skill in skills.values() {
catalog.push_str(&format!(
"\n- {} [{}]: {}",
skill.frontmatter.name, skill.execution, skill.frontmatter.description
));
}
catalog
}
}
impl SubAgentSet for SkillManager {
fn into_any(self: Arc<Self>) -> Arc<dyn Any + Send + Sync> {
self
}
fn contains_lowercase(&self, lowercase_name: &str) -> bool {
self.subagents.read().contains_key(lowercase_name)
}
fn get_lowercase(&self, lowercase_name: &str) -> Option<SubAgent> {
self.subagents.read().get(lowercase_name).cloned()
}
fn definitions(&self, names: Option<&[String]>) -> Vec<FunctionDefinition> {
let subagents = self.subagents.read();
match names {
None => subagents.values().map(|agent| agent.definition()).collect(),
Some(names) => names
.iter()
.filter_map(|name| {
subagents
.get(&name.to_ascii_lowercase())
.map(|agent| agent.definition())
})
.collect(),
}
}
fn select_resources(&self, name: &str, resources: &mut Vec<Resource>) -> Vec<Resource> {
if resources.is_empty() {
return Vec::new();
}
self.subagents
.read()
.get(&name.to_ascii_lowercase())
.map(|agent| {
let supported_tags = agent.supported_resource_tags();
select_resources(resources, &supported_tags)
})
.unwrap_or_default()
}
}
impl Tool<BaseCtx> for SkillManager {
type Args = SkillArgs;
type Output = SkillContentOutput;
fn name(&self) -> String {
Self::NAME.to_string()
}
fn description(&self) -> String {
format!("{}{}", self.description, self.skills_catalog())
}
fn definition(&self) -> FunctionDefinition {
let mut definition = tool_definition::<Self::Args>(self.name(), self.description());
definition.parameters["description"] = "Read a reusable skill's SKILL.md file content by skill name. Create or update skills by editing files directly with shell or file tools, then reload the manager.".into();
definition
}
async fn call(
&self,
ctx: BaseCtx,
args: Self::Args,
_resources: Vec<Resource>,
) -> Result<ToolOutput<Self::Output>, BoxError> {
hooked_call(&ctx, args, |args| async move {
Ok(ToolOutput::new(self.read_skill_action(args).await?))
})
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{context::BaseCtx, engine::EngineBuilder, subagent::SubAgentSet};
use serde_json::json;
use std::sync::Arc;
fn mock_ctx() -> BaseCtx {
EngineBuilder::new().mock_ctx().base
}
fn skill_md(name: &str, description: &str, body: &str, frontmatter: &[&str]) -> String {
let mut content = format!("---\nname: {name}\ndescription: {description}\n");
for line in frontmatter {
content.push_str(line);
content.push('\n');
}
content.push_str("---\n\n");
content.push_str(body);
if !body.ends_with('\n') {
content.push('\n');
}
content
}
#[test]
fn skill_manager_tool_definition_schema() {
let mgr = SkillManager::new(PathBuf::from("/tmp/skills"));
let def = mgr.definition();
assert_eq!(def.name, "skills_manager");
assert!(def.description.contains("Agent Skills specification"));
assert_eq!(def.parameters["additionalProperties"], json!(false));
assert_eq!(def.parameters["required"], json!(["name"]));
assert!(def.parameters["properties"].get("action").is_none());
}
#[tokio::test]
async fn load_and_read_from_temp_dir() {
let tmp =
std::env::temp_dir().join(format!("anda-skills-test-{:016x}", rand::random::<u64>()));
tokio::fs::create_dir_all(tmp.join("alpha")).await.unwrap();
tokio::fs::create_dir_all(tmp.join("beta-skill"))
.await
.unwrap();
tokio::fs::write(
tmp.join("alpha/SKILL.md"),
"\
---
name: alpha
description: Alpha skill for testing.
---
Alpha instructions.
",
)
.await
.unwrap();
tokio::fs::write(
tmp.join("beta-skill/SKILL.md"),
"\
---
name: beta-skill
description: Beta skill for testing.
license: MIT
execution: subagent
allowed-tools: shell fetch
---
Beta instructions.
",
)
.await
.unwrap();
let mgr = SkillManager::new(tmp.clone());
mgr.load().await.unwrap();
assert!(mgr.list().contains_key("skill_alpha"));
assert!(!mgr.contains_lowercase("skill_alpha"));
assert!(mgr.get_lowercase("skill_alpha").is_none());
assert!(mgr.contains_lowercase("skill_beta_skill"));
assert!(!mgr.contains_lowercase("skill_gamma"));
let beta = mgr.get_lowercase("skill_beta_skill").unwrap();
assert_eq!(beta.tools, vec!["shell", "fetch"]);
assert!(beta.instructions.contains("Beta instructions."));
let beta_skill = mgr.get_skill("skill_beta_skill").unwrap();
assert_eq!(beta_skill.frontmatter.license.as_deref(), Some("MIT"));
let beta_content = mgr
.call_raw(mock_ctx(), json!({ "name": "beta-skill" }), Vec::new())
.await
.unwrap();
assert_eq!(beta_content.output["name"], json!("beta-skill"));
assert_eq!(beta_content.output["execution"], json!("subagent"));
assert_eq!(
beta_content.output["callable"],
json!("SA_skill_beta_skill")
);
assert_eq!(beta_content.output["path"], json!("beta-skill/SKILL.md"));
assert_eq!(
beta_content.output["base_dir"],
json!(tmp.join("beta-skill").display().to_string())
);
assert!(
beta_content.output["content"]
.as_str()
.unwrap()
.contains("Beta instructions.")
);
let alpha_content = mgr
.call_raw(mock_ctx(), json!({ "name": "alpha" }), Vec::new())
.await
.unwrap();
assert_eq!(alpha_content.output["execution"], json!("inline"));
assert!(alpha_content.output.get("callable").is_none());
tokio::fs::create_dir_all(tmp.join("gamma")).await.unwrap();
tokio::fs::write(
tmp.join("gamma/SKILL.md"),
skill_md(
"gamma",
"Gamma skill for testing.",
"Gamma instructions.",
&["execution: subagent"],
),
)
.await
.unwrap();
mgr.load().await.unwrap();
assert!(mgr.contains_lowercase("skill_gamma"));
assert!(tmp.join("gamma/SKILL.md").exists());
assert_eq!(
mgr.get_lowercase("skill_gamma").unwrap().tools,
DEFAULT_SKILL_TOOLS
);
let on_disk = tokio::fs::read_to_string(tmp.join("gamma/SKILL.md"))
.await
.unwrap();
let reparsed = parse_skill_md(tmp.to_path_buf(), &on_disk).unwrap();
assert_eq!(reparsed.frontmatter.name, "gamma");
let defs = mgr.definitions(None);
assert_eq!(defs.len(), 2);
assert!(!defs.iter().any(|def| def.name == "skill_alpha"));
let defs_filtered = mgr.definitions(Some(&["skill_gamma".to_string()]));
assert_eq!(defs_filtered.len(), 1);
assert_eq!(defs_filtered[0].name, "skill_gamma");
assert!(
mgr.definitions(Some(&["skill_alpha".to_string()]))
.is_empty()
);
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
#[tokio::test(flavor = "current_thread")]
async fn reading_a_skill_refreshes_the_materialized_subagent() {
let root = std::env::temp_dir().join(format!(
"anda-skills-read-refresh-{:016x}",
rand::random::<u64>()
));
let skill_dir = root.join("alpha");
tokio::fs::create_dir_all(&skill_dir).await.unwrap();
tokio::fs::write(
skill_dir.join("SKILL.md"),
skill_md(
"alpha",
"Alpha skill before refresh.",
"Original instructions.",
&["execution: subagent"],
),
)
.await
.unwrap();
let mgr = SkillManager::new(root.clone());
mgr.call_raw(mock_ctx(), json!({"name": "alpha"}), Vec::new())
.await
.unwrap();
let before = mgr
.get_lowercase("skill_alpha")
.expect("the directly read skill must be callable");
assert!(before.instructions.contains("Original instructions."));
tokio::fs::write(
skill_dir.join("SKILL.md"),
skill_md(
"alpha",
"Alpha skill after refresh.",
"Updated instructions.",
&["execution: subagent"],
),
)
.await
.unwrap();
mgr.call_raw(mock_ctx(), json!({"name": "alpha"}), Vec::new())
.await
.unwrap();
let after = mgr
.get_lowercase("skill_alpha")
.expect("the refreshed skill must remain callable");
assert_eq!(after.description, "Alpha skill after refresh.");
assert!(after.instructions.contains("Updated instructions."));
assert!(
Arc::ptr_eq(&before.subsessions, &after.subsessions),
"refreshing instructions must not disconnect live sessions"
);
assert_eq!(
mgr.definitions(Some(&["skill_alpha".to_string()]))[0].description,
after.definition().description
);
tokio::fs::write(
skill_dir.join("SKILL.md"),
skill_md(
"alpha",
"Alpha skill, now inline.",
"Inline instructions.",
&[],
),
)
.await
.unwrap();
let output = mgr
.call_raw(mock_ctx(), json!({"name": "alpha"}), Vec::new())
.await
.unwrap();
assert_eq!(output.output["execution"], json!("inline"));
assert!(mgr.get_lowercase("skill_alpha").is_none());
assert!(mgr.list().contains_key("skill_alpha"));
let _ = tokio::fs::remove_dir_all(&root).await;
}
#[tokio::test]
async fn load_and_read_platform_encoded_skill_file_when_available() {
let Some(encoding) =
anda_core::platform_text_encoding().filter(|encoding| encoding.name() != "UTF-8")
else {
return;
};
let Some(marker) = [
"中文",
"café",
"日本語",
"한국어",
"тест",
"γειά",
"שלום",
"مرحبا",
]
.into_iter()
.find(|candidate| {
let (bytes, _, had_errors) = encoding.encode(candidate);
!had_errors && std::str::from_utf8(&bytes).is_err()
}) else {
return;
};
let tmp =
std::env::temp_dir().join(format!("anda-skills-legacy-{:016x}", rand::random::<u64>()));
tokio::fs::create_dir_all(tmp.join("legacy-skill"))
.await
.unwrap();
let body = format!("Legacy encoded skill marker: {marker}");
let content = skill_md(
"legacy-skill",
"Legacy encoded skill for testing.",
&body,
&["execution: subagent"],
);
let (encoded, _, had_errors) = encoding.encode(&content);
assert!(!had_errors);
assert!(std::str::from_utf8(encoded.as_ref()).is_err());
tokio::fs::write(tmp.join("legacy-skill/SKILL.md"), encoded.as_ref())
.await
.unwrap();
let mgr = SkillManager::new(tmp.clone());
mgr.load().await.unwrap();
assert!(mgr.contains_lowercase("skill_legacy_skill"));
let agent = mgr.get_lowercase("skill_legacy_skill").unwrap();
assert!(agent.instructions.contains(&body));
let output = mgr
.call_raw(mock_ctx(), json!({ "name": "legacy-skill" }), Vec::new())
.await
.unwrap();
assert_eq!(output.output["name"], json!("legacy-skill"));
assert!(output.output["content"].as_str().unwrap().contains(&body));
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
#[tokio::test]
async fn load_and_read_from_multiple_dirs() {
let root =
std::env::temp_dir().join(format!("anda-skills-multi-{:016x}", rand::random::<u64>()));
let default_dir = root.join("default");
let extra_dir = root.join("extra");
tokio::fs::create_dir_all(default_dir.join("alpha"))
.await
.unwrap();
tokio::fs::create_dir_all(extra_dir.join("beta"))
.await
.unwrap();
tokio::fs::write(
default_dir.join("alpha/SKILL.md"),
skill_md(
"alpha",
"Alpha skill from default directory.",
"Alpha instructions.",
&[],
),
)
.await
.unwrap();
tokio::fs::write(
extra_dir.join("beta/SKILL.md"),
skill_md(
"beta",
"Beta skill from extra directory.",
"Beta instructions.",
&["execution: subagent"],
),
)
.await
.unwrap();
let mgr = SkillManager::new_with_dirs(
default_dir.clone(),
vec![extra_dir.clone(), default_dir.clone()],
);
let expected_dirs = vec![default_dir.clone(), extra_dir.clone()];
assert_eq!(mgr.default_skills_dir(), default_dir.as_path());
assert_eq!(mgr.skills_dirs(), expected_dirs.as_slice());
mgr.load().await.unwrap();
assert!(mgr.list().contains_key("skill_alpha"));
assert!(mgr.contains_lowercase("skill_beta"));
let beta_content = mgr
.call_raw(mock_ctx(), json!({ "name": "beta" }), Vec::new())
.await
.unwrap();
assert_eq!(beta_content.output["name"], json!("beta"));
assert_eq!(beta_content.output["callable"], json!("SA_skill_beta"));
assert_eq!(beta_content.output["path"], json!("beta/SKILL.md"));
assert!(
beta_content.output["content"]
.as_str()
.unwrap()
.contains("Beta instructions.")
);
assert!(mgr.default_skills_dir().ends_with("default"));
let _ = tokio::fs::remove_dir_all(&root).await;
}
#[tokio::test(flavor = "current_thread")]
async fn manager_custom_options_lists_subagents_and_selects_resource_paths() {
let root = std::env::temp_dir().join(format!(
"anda-skills-manager-{:016x}",
rand::random::<u64>()
));
tokio::fs::create_dir_all(root.join("alpha")).await.unwrap();
tokio::fs::write(
root.join("alpha/SKILL.md"),
skill_md(
"alpha",
"Alpha skill for manager coverage.",
"Alpha body.",
&[
"execution: subagent",
"allowed-tools: shell todo shell custom_tool",
],
),
)
.await
.unwrap();
tokio::fs::create_dir_all(root.join("inline-one"))
.await
.unwrap();
tokio::fs::write(
root.join("inline-one/SKILL.md"),
skill_md(
"inline-one",
"Inline skill for manager coverage.",
"Inline body.",
&[],
),
)
.await
.unwrap();
tokio::fs::create_dir_all(root.join("locked-down"))
.await
.unwrap();
tokio::fs::write(
root.join("locked-down/SKILL.md"),
skill_md(
"locked-down",
"Delegates but asks for no tools.",
"Locked body.",
&["execution: subagent", "allowed-tools: []"],
),
)
.await
.unwrap();
let mgr = Arc::new(
SkillManager::new(root.clone())
.with_description("custom skill reader".to_string())
.with_default_skill_tools(vec!["read_file".to_string(), "todo".to_string()]),
);
assert_eq!(mgr.description(), "custom skill reader");
assert_eq!(mgr.list().len(), 0);
mgr.load().await.unwrap();
assert_eq!(mgr.list().len(), 3);
let description = mgr.description();
assert!(
description.starts_with("custom skill reader"),
"{description}"
);
assert!(
description.contains("- inline-one [inline]: Inline skill for manager coverage."),
"{description}"
);
assert!(
description.contains("- alpha [subagent]: Alpha skill for manager coverage."),
"{description}"
);
let subagents = mgr.subagents();
assert_eq!(subagents.len(), 2);
assert_eq!(subagents[0].name, "skill_alpha");
assert_eq!(subagents[0].tools, vec!["shell", "todo", "custom_tool"]);
assert_eq!(subagents[1].name, "skill_locked_down");
assert!(subagents[1].tools.is_empty());
let any = mgr.clone().into_any();
assert!(any.downcast_ref::<SkillManager>().is_some());
let mut resources = vec![Resource {
_id: 1,
name: "text".to_string(),
tags: vec!["text".to_string()],
..Default::default()
}];
assert!(SubAgentSet::select_resources(mgr.as_ref(), "missing", &mut resources).is_empty());
assert!(
SubAgentSet::select_resources(mgr.as_ref(), "skill_inline_one", &mut resources)
.is_empty()
);
assert_eq!(resources.len(), 1);
let selected = SubAgentSet::select_resources(mgr.as_ref(), "skill_alpha", &mut resources);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].name, "text");
assert!(resources.is_empty());
assert!(
SubAgentSet::select_resources(mgr.as_ref(), "skill_alpha", &mut resources).is_empty()
);
let _ = tokio::fs::remove_dir_all(&root).await;
}
#[tokio::test(flavor = "current_thread")]
async fn manager_finds_frontmatter_names_and_reports_duplicates_or_bad_files() {
let root =
std::env::temp_dir().join(format!("anda-skills-find-{:016x}", rand::random::<u64>()));
let default_dir = root.join("default");
let extra_dir = root.join("extra");
tokio::fs::create_dir_all(default_dir.join("folder-name"))
.await
.unwrap();
tokio::fs::create_dir_all(extra_dir.join("duplicate-one"))
.await
.unwrap();
tokio::fs::create_dir_all(extra_dir.join("duplicate-two"))
.await
.unwrap();
tokio::fs::create_dir_all(extra_dir.join("bad"))
.await
.unwrap();
tokio::fs::write(
default_dir.join("folder-name/SKILL.md"),
skill_md(
"frontmatter-name",
"Looked up by parsed frontmatter.",
"Frontmatter body.",
&["execution: subagent"],
),
)
.await
.unwrap();
tokio::fs::write(
extra_dir.join("duplicate-one/SKILL.md"),
skill_md("dupe", "Duplicate one.", "One.", &[]),
)
.await
.unwrap();
tokio::fs::write(
extra_dir.join("duplicate-two/SKILL.md"),
skill_md("dupe", "Duplicate two.", "Two.", &[]),
)
.await
.unwrap();
tokio::fs::write(extra_dir.join("bad/SKILL.md"), "not frontmatter")
.await
.unwrap();
let mgr = SkillManager::new_with_dirs(default_dir.clone(), vec![extra_dir.clone()]);
mgr.load().await.unwrap();
assert!(mgr.contains_lowercase("skill_frontmatter_name"));
let read = mgr
.call_raw(mock_ctx(), json!({"name": "frontmatter-name"}), Vec::new())
.await
.unwrap();
assert_eq!(read.output["callable"], json!("SA_skill_frontmatter_name"));
assert_eq!(read.output["path"], json!("folder-name/SKILL.md"));
let duplicate = mgr
.call_raw(mock_ctx(), json!({"name": "dupe"}), Vec::new())
.await
.unwrap_err();
assert!(duplicate.to_string().contains("multiple skills named"));
let missing = mgr
.call_raw(mock_ctx(), json!({"name": "missing"}), Vec::new())
.await
.unwrap_err();
assert!(missing.to_string().contains("skill \"missing\" not found"));
let invalid = mgr
.call_raw(mock_ctx(), json!({"name": "Bad"}), Vec::new())
.await
.unwrap_err();
assert!(invalid.to_string().contains("invalid character"));
let _ = tokio::fs::remove_dir_all(&root).await;
}
#[tokio::test(flavor = "current_thread")]
async fn manager_read_rejects_unsafe_large_non_utf8_or_mismatched_skill_files() {
let root =
std::env::temp_dir().join(format!("anda-skills-errors-{:016x}", rand::random::<u64>()));
tokio::fs::create_dir_all(root.join("mismatch"))
.await
.unwrap();
tokio::fs::write(
root.join("mismatch/SKILL.md"),
skill_md(
"other-name",
"Mismatched frontmatter name.",
"Mismatch body.",
&[],
),
)
.await
.unwrap();
let mgr = SkillManager::new(root.clone());
let mismatch = mgr
.call_raw(mock_ctx(), json!({"name": "mismatch"}), Vec::new())
.await
.unwrap_err();
assert!(
mismatch
.to_string()
.contains("must match requested skill name")
);
tokio::fs::create_dir_all(root.join("binary"))
.await
.unwrap();
tokio::fs::write(root.join("binary/SKILL.md"), vec![0x81, 0x00])
.await
.unwrap();
let binary = mgr
.call_raw(mock_ctx(), json!({"name": "binary"}), Vec::new())
.await
.unwrap_err();
assert!(
binary
.to_string()
.contains("Only UTF-8 or supported text-encoded skill files")
);
tokio::fs::create_dir_all(root.join("large")).await.unwrap();
tokio::fs::write(
root.join("large/SKILL.md"),
vec![b'a'; MAX_SKILL_FILE_BYTES as usize + 1],
)
.await
.unwrap();
let large = mgr
.call_raw(mock_ctx(), json!({"name": "large"}), Vec::new())
.await
.unwrap_err();
assert!(large.to_string().contains("exceeds maximum"));
let missing_dirs = SkillManager::new(root.join("missing-default"));
missing_dirs.load().await.unwrap();
assert!(missing_dirs.list().is_empty());
let _ = tokio::fs::remove_dir_all(&root).await;
}
#[tokio::test]
async fn load_uses_frontmatter_name_when_dir_differs() {
let tmp = std::env::temp_dir().join(format!(
"anda-skills-mismatch-{:016x}",
rand::random::<u64>()
));
tokio::fs::create_dir_all(tmp.join("wrong-dir"))
.await
.unwrap();
tokio::fs::write(
tmp.join("wrong-dir/SKILL.md"),
"\
---
name: correct-name
description: Name does not match directory.
---
Body.
",
)
.await
.unwrap();
let mgr = SkillManager::new(tmp.clone());
mgr.load().await.unwrap();
assert!(mgr.list().contains_key("skill_correct_name"));
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
#[tokio::test(flavor = "current_thread")]
async fn tool_requires_name() {
let tmp = std::env::temp_dir().join(format!(
"anda-skills-requires-name-{:016x}",
rand::random::<u64>()
));
let mgr = SkillManager::new(tmp.clone());
let err = mgr
.call_raw(mock_ctx(), json!({}), Vec::new())
.await
.unwrap_err();
assert!(err.to_string().contains("missing field `name`"));
}
#[tokio::test(flavor = "current_thread")]
async fn tool_rejects_mutation_fields() {
let tmp = std::env::temp_dir().join(format!(
"anda-skills-rejects-action-{:016x}",
rand::random::<u64>()
));
let mgr = SkillManager::new(tmp.clone());
let err = mgr
.call_raw(
mock_ctx(),
json!({
"action": "create",
"name": "golf"
}),
Vec::new(),
)
.await
.unwrap_err();
assert!(err.to_string().contains("unknown field `action`"));
}
#[tokio::test(flavor = "current_thread")]
async fn sub_agents_manager_register_skills_manager() {
let tmp =
std::env::temp_dir().join(format!("anda-skills-val-{:016x}", rand::random::<u64>()));
let tool = SkillManager::new(tmp.clone());
let engine = EngineBuilder::new().empty().await.unwrap();
assert!(engine.sub_agents_manager().insert(Arc::new(tool)).is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn name_scan_reuses_parsed_files_until_they_change() {
let root = std::env::temp_dir().join(format!(
"anda-skills-parse-cache-{:016x}",
rand::random::<u64>()
));
write_subagent_skill(&root, "worker", "worker", "First instructions.").await;
let mgr = SkillManager::new(root.clone());
mgr.load().await.unwrap();
let read = mgr
.call_raw(mock_ctx(), json!({ "name": "worker" }), Vec::new())
.await
.unwrap();
assert!(
read.output["content"]
.as_str()
.unwrap()
.contains("First instructions.")
);
assert_eq!(mgr.parsed_files.lock().len(), 1);
write_subagent_skill(&root, "worker", "renamed-worker", "Second, longer text.").await;
let read = mgr
.call_raw(mock_ctx(), json!({ "name": "renamed-worker" }), Vec::new())
.await
.unwrap();
assert!(
read.output["content"]
.as_str()
.unwrap()
.contains("Second, longer text.")
);
let err = mgr
.call_raw(mock_ctx(), json!({ "name": "worker" }), Vec::new())
.await
.unwrap_err();
assert!(err.to_string().contains("renamed-worker"), "{err}");
let _ = tokio::fs::remove_dir_all(&root).await;
}
async fn write_subagent_skill(root: &Path, dir: &str, name: &str, body: &str) {
let skill_dir = root.join(dir);
tokio::fs::create_dir_all(&skill_dir).await.unwrap();
tokio::fs::write(
skill_dir.join("SKILL.md"),
skill_md(
name,
&format!("{name} skill for filter testing."),
body,
&["execution: subagent"],
),
)
.await
.unwrap();
}
#[tokio::test(flavor = "current_thread")]
async fn a_rejected_skill_is_invisible_everywhere() {
let root =
std::env::temp_dir().join(format!("anda-skills-filter-{:016x}", rand::random::<u64>()));
write_subagent_skill(&root, "kept", "kept", "Kept instructions.").await;
write_subagent_skill(&root, "hidden", "hidden", "Hidden instructions.").await;
let mgr = SkillManager::new(root.clone());
mgr.set_skill_filter(Some(Arc::new(|skill: &Skill| {
skill.frontmatter.name != "hidden"
})));
mgr.load().await.unwrap();
assert!(mgr.list().contains_key("skill_kept"));
assert!(mgr.contains_lowercase("skill_kept"));
assert!(!mgr.list().contains_key("skill_hidden"));
assert!(!mgr.contains_lowercase("skill_hidden"));
assert!(mgr.get_skill("skill_hidden").is_none());
assert!(!mgr.description().contains("hidden"));
let err = mgr
.call_raw(mock_ctx(), json!({ "name": "hidden" }), Vec::new())
.await
.unwrap_err();
assert!(err.to_string().contains("not found"), "{err}");
assert!(!mgr.contains_lowercase("skill_hidden"));
let _ = tokio::fs::remove_dir_all(&root).await;
}
#[tokio::test(flavor = "current_thread")]
async fn rejecting_the_winning_copy_promotes_the_next_directory() {
let root = std::env::temp_dir().join(format!(
"anda-skills-filter-shadow-{:016x}",
rand::random::<u64>()
));
let personal = root.join("personal");
let bundled = root.join("bundled");
write_subagent_skill(&personal, "dup", "dup", "Personal instructions.").await;
write_subagent_skill(&bundled, "dup", "dup", "Bundled instructions.").await;
let mgr = SkillManager::new_with_dirs(personal.clone(), vec![bundled.clone()]);
mgr.load().await.unwrap();
assert!(
mgr.get_lowercase("skill_dup")
.unwrap()
.instructions
.contains("Personal instructions.")
);
let personal_dir = personal.join("dup");
mgr.set_skill_filter(Some(Arc::new(move |skill: &Skill| {
skill.base_dir != personal_dir
})));
mgr.load().await.unwrap();
assert!(
mgr.get_lowercase("skill_dup")
.unwrap()
.instructions
.contains("Bundled instructions.")
);
let _ = tokio::fs::remove_dir_all(&root).await;
}
#[tokio::test(flavor = "current_thread")]
async fn reading_a_shadowed_name_resolves_by_directory_priority() {
let root = std::env::temp_dir().join(format!(
"anda-skills-read-shadow-{:016x}",
rand::random::<u64>()
));
let personal = root.join("personal");
let bundled = root.join("bundled");
write_subagent_skill(&personal, "dup", "dup", "Personal instructions.").await;
write_subagent_skill(&bundled, "dup", "dup", "Bundled instructions.").await;
let mgr = SkillManager::new_with_dirs(personal.clone(), vec![bundled.clone()]);
mgr.load().await.unwrap();
let read = mgr
.call_raw(mock_ctx(), json!({ "name": "dup" }), Vec::new())
.await
.unwrap();
assert!(
read.output["content"]
.as_str()
.unwrap()
.contains("Personal instructions.")
);
write_subagent_skill(&personal, "dup-alias", "dup", "Second personal copy.").await;
let err = mgr
.call_raw(mock_ctx(), json!({ "name": "dup" }), Vec::new())
.await
.unwrap_err();
assert!(err.to_string().contains("multiple skills named"), "{err}");
let _ = tokio::fs::remove_dir_all(&root).await;
}
#[tokio::test(flavor = "current_thread")]
async fn installing_a_filter_drops_what_it_rejects_without_a_reload() {
let root = std::env::temp_dir().join(format!(
"anda-skills-filter-prune-{:016x}",
rand::random::<u64>()
));
write_subagent_skill(&root, "alpha", "alpha", "Alpha instructions.").await;
let mgr = SkillManager::new(root.clone());
mgr.load().await.unwrap();
assert!(mgr.contains_lowercase("skill_alpha"));
mgr.set_skill_filter(Some(Arc::new(|_: &Skill| false)));
assert!(mgr.list().is_empty());
assert!(!mgr.contains_lowercase("skill_alpha"));
mgr.set_skill_filter(None);
assert!(mgr.list().is_empty());
mgr.load().await.unwrap();
assert!(mgr.contains_lowercase("skill_alpha"));
let _ = tokio::fs::remove_dir_all(&root).await;
}
}