pub mod builtin;
pub mod parser;
pub mod storage;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::sync::RwLock;
use tracing::info;
use crate::skills::store::builtin::load_builtin_skill_bundles;
use crate::skills::store::parser::render_skill_markdown;
use crate::skills::store::storage::{
ensure_skills_dir, load_skills_from_discovery_dirs, write_skill_file, SkillDirectorySource,
SkillDiscoveryDir,
};
use crate::skills::types::{
SkillDefinition, SkillError, SkillFilter, SkillId, SkillResult, SkillStoreConfig,
};
#[derive(Debug, Clone, PartialEq, Eq)]
struct SkillCandidateMeta {
source: SkillDirectorySource,
mode: Option<String>,
}
pub struct SkillStore {
skills: RwLock<HashMap<SkillId, SkillDefinition>>,
skill_roots: RwLock<HashMap<SkillId, PathBuf>>,
config: SkillStoreConfig,
}
impl SkillStore {
fn normalize_mode(raw_mode: Option<&str>) -> Option<String> {
let raw = raw_mode?.trim();
if raw.is_empty() {
return None;
}
let normalized = raw.to_ascii_lowercase();
if !normalized.chars().all(|character| {
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
}) {
tracing::warn!(
"Ignoring invalid skill mode '{}' (allowed: lowercase letters, digits, hyphen)",
raw
);
return None;
}
Some(normalized)
}
fn effective_mode(&self, mode_override: Option<&str>) -> Option<String> {
Self::normalize_mode(mode_override)
.or_else(|| Self::normalize_mode(self.config.active_mode.as_deref()))
}
fn sibling_skills_mode_dir(base_skills_dir: &Path, mode: &str) -> PathBuf {
let parent = base_skills_dir
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
parent.join(format!("skills-{mode}"))
}
fn project_skills_dir(project_dir: &Path) -> PathBuf {
project_dir.join(".bamboo").join("skills")
}
fn project_skills_mode_dir(project_dir: &Path, mode: &str) -> PathBuf {
project_dir.join(".bamboo").join(format!("skills-{mode}"))
}
fn discovery_dirs_for_mode(&self, mode_override: Option<&str>) -> Vec<SkillDiscoveryDir> {
let mut dirs = Vec::new();
let active_mode = self.effective_mode(mode_override);
dirs.push(SkillDiscoveryDir {
dir: self.config.skills_dir.clone(),
source: SkillDirectorySource::Global,
mode: None,
});
if let Some(mode) = active_mode.as_ref() {
dirs.push(SkillDiscoveryDir {
dir: Self::sibling_skills_mode_dir(&self.config.skills_dir, mode),
source: SkillDirectorySource::Global,
mode: Some(mode.clone()),
});
}
if let Some(project_dir) = self.config.project_dir.as_ref() {
dirs.push(SkillDiscoveryDir {
dir: Self::project_skills_dir(project_dir),
source: SkillDirectorySource::Project,
mode: None,
});
if let Some(mode) = active_mode.as_ref() {
dirs.push(SkillDiscoveryDir {
dir: Self::project_skills_mode_dir(project_dir, mode),
source: SkillDirectorySource::Project,
mode: Some(mode.clone()),
});
}
}
dirs
}
fn resolve_from_loaded_records(
loaded_records: Vec<crate::skills::store::storage::LoadedSkillRecord>,
) -> (HashMap<SkillId, SkillDefinition>, HashMap<SkillId, PathBuf>) {
let mut resolved_skills: HashMap<SkillId, SkillDefinition> = HashMap::new();
let mut resolved_roots: HashMap<SkillId, PathBuf> = HashMap::new();
let mut resolved_meta: HashMap<SkillId, SkillCandidateMeta> = HashMap::new();
for record in loaded_records {
let skill_id = record.skill.id.clone();
let candidate_meta = SkillCandidateMeta {
source: record.source,
mode: record.mode.clone(),
};
let should_replace = resolved_meta
.get(&skill_id)
.is_some_and(|existing| Self::should_override_skill(existing, &candidate_meta));
let should_keep_existing = resolved_meta.contains_key(&skill_id) && !should_replace;
if should_keep_existing {
tracing::debug!(
"Keeping existing skill '{}' over candidate from {:?} (mode={})",
skill_id,
candidate_meta.source,
candidate_meta.mode.as_deref().unwrap_or("generic")
);
continue;
}
if should_replace {
tracing::info!(
"Skill '{}' overridden by {:?} (mode={})",
skill_id,
candidate_meta.source,
candidate_meta.mode.as_deref().unwrap_or("generic")
);
}
resolved_skills.insert(skill_id.clone(), record.skill);
resolved_roots.insert(skill_id.clone(), record.skill_root);
resolved_meta.insert(skill_id, candidate_meta);
}
(resolved_skills, resolved_roots)
}
async fn resolve_skills_maps_for_mode(
&self,
mode_override: Option<&str>,
) -> SkillResult<(HashMap<SkillId, SkillDefinition>, HashMap<SkillId, PathBuf>)> {
let loaded_records =
load_skills_from_discovery_dirs(&self.discovery_dirs_for_mode(mode_override)).await?;
Ok(Self::resolve_from_loaded_records(loaded_records))
}
fn should_override_skill(
existing: &SkillCandidateMeta,
candidate: &SkillCandidateMeta,
) -> bool {
match (existing.source, candidate.source) {
(SkillDirectorySource::Global, SkillDirectorySource::Project) => return true,
(SkillDirectorySource::Project, SkillDirectorySource::Global) => return false,
_ => {}
}
match (existing.mode.is_some(), candidate.mode.is_some()) {
(false, true) => true,
(true, false) => false,
_ => false,
}
}
pub fn new(config: SkillStoreConfig) -> Self {
Self {
skills: RwLock::new(HashMap::new()),
skill_roots: RwLock::new(HashMap::new()),
config,
}
}
pub async fn initialize(&self) -> SkillResult<()> {
info!("Initializing skill store...");
ensure_skills_dir(&self.config.skills_dir).await?;
self.create_builtin_skills().await?;
self.load().await?;
info!("Skill store initialized");
Ok(())
}
async fn load(&self) -> SkillResult<usize> {
let (resolved_skills, resolved_roots) = self.resolve_skills_maps_for_mode(None).await?;
let count = resolved_skills.len();
let mut skills = self.skills.write().await;
let mut roots = self.skill_roots.write().await;
*skills = resolved_skills;
*roots = resolved_roots;
Ok(count)
}
async fn create_builtin_skills(&self) -> SkillResult<()> {
for bundle in load_builtin_skill_bundles()? {
let skill_id = bundle.skill.id.clone();
write_skill_file(&self.config.skills_dir, &bundle.skill).await?;
for (relative_path, content) in bundle.files {
let full_path = self.config.skills_dir.join(&skill_id).join(&relative_path);
if let Some(parent) = full_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&full_path, content).await?;
#[cfg(unix)]
{
if relative_path.starts_with("scripts/") {
use std::os::unix::fs::PermissionsExt;
let mut perms = tokio::fs::metadata(&full_path).await?.permissions();
perms.set_mode(0o755);
tokio::fs::set_permissions(&full_path, perms).await?;
}
}
}
}
Ok(())
}
pub async fn reload(&self) -> SkillResult<usize> {
info!("Reloading skills from disk...");
self.load().await
}
pub async fn list_skills(
&self,
filter: Option<SkillFilter>,
refresh: bool,
) -> Vec<SkillDefinition> {
if refresh {
if let Err(e) = self.reload().await {
tracing::warn!("Failed to reload skills: {}", e);
}
}
let skills = self.skills.read().await;
let mut result: Vec<SkillDefinition> = skills
.values()
.filter(|skill| match &filter {
Some(active_filter) => active_filter.matches(skill),
None => true,
})
.cloned()
.collect();
result.sort_by_key(|s| s.name.clone());
result
}
pub async fn list_skills_for_mode(
&self,
filter: Option<SkillFilter>,
mode_override: Option<&str>,
) -> Vec<SkillDefinition> {
let (skills, _) = match self.resolve_skills_maps_for_mode(mode_override).await {
Ok(maps) => maps,
Err(error) => {
tracing::warn!(
"Failed to resolve skills for mode {:?}: {}",
mode_override,
error
);
return Vec::new();
}
};
let mut result: Vec<SkillDefinition> = skills
.values()
.filter(|skill| match &filter {
Some(active_filter) => active_filter.matches(skill),
None => true,
})
.cloned()
.collect();
result.sort_by_key(|s| s.name.clone());
result
}
pub async fn get_skill(&self, id: &str) -> SkillResult<SkillDefinition> {
let skills = self.skills.read().await;
skills
.get(id)
.cloned()
.ok_or_else(|| SkillError::NotFound(id.to_string()))
}
pub async fn get_skill_for_mode(
&self,
id: &str,
mode_override: Option<&str>,
) -> SkillResult<SkillDefinition> {
if mode_override.is_none() {
return self.get_skill(id).await;
}
let (skills, _) = self.resolve_skills_maps_for_mode(mode_override).await?;
skills
.get(id)
.cloned()
.ok_or_else(|| SkillError::NotFound(id.to_string()))
}
pub async fn get_skill_root(&self, id: &str) -> SkillResult<PathBuf> {
let roots = self.skill_roots.read().await;
roots
.get(id)
.cloned()
.ok_or_else(|| SkillError::NotFound(id.to_string()))
}
pub async fn get_skill_root_for_mode(
&self,
id: &str,
mode_override: Option<&str>,
) -> SkillResult<PathBuf> {
if mode_override.is_none() {
return self.get_skill_root(id).await;
}
let (_, roots) = self.resolve_skills_maps_for_mode(mode_override).await?;
roots
.get(id)
.cloned()
.ok_or_else(|| SkillError::NotFound(id.to_string()))
}
pub async fn create_skill(&self, _skill: SkillDefinition) -> SkillResult<SkillDefinition> {
Err(SkillError::ReadOnly(
"Skills are read-only and must be edited as Markdown files".to_string(),
))
}
pub async fn update_skill(
&self,
_id: &str,
_updates: SkillUpdate,
) -> SkillResult<SkillDefinition> {
Err(SkillError::ReadOnly(
"Skills are read-only and must be edited as Markdown files".to_string(),
))
}
pub async fn delete_skill(&self, _id: &str) -> SkillResult<()> {
Err(SkillError::ReadOnly(
"Skills are read-only and must be edited as Markdown files".to_string(),
))
}
pub async fn enable_skill_global(&self, _id: &str) -> SkillResult<()> {
Err(SkillError::ReadOnly(
"Skills are read-only and must be edited as Markdown files".to_string(),
))
}
pub async fn disable_skill_global(&self, _id: &str) -> SkillResult<()> {
Err(SkillError::ReadOnly(
"Skills are read-only and must be edited as Markdown files".to_string(),
))
}
pub async fn enable_skill_for_chat(&self, _skill_id: &str, _chat_id: &str) -> SkillResult<()> {
Err(SkillError::ReadOnly(
"Skills are read-only and must be edited as Markdown files".to_string(),
))
}
pub async fn disable_skill_for_chat(&self, _skill_id: &str, _chat_id: &str) -> SkillResult<()> {
Err(SkillError::ReadOnly(
"Skills are read-only and must be edited as Markdown files".to_string(),
))
}
pub async fn get_all_skills(&self) -> Vec<SkillDefinition> {
let mut skills: Vec<SkillDefinition> = self.skills.read().await.values().cloned().collect();
skills.sort_by_key(|s| s.name.clone());
skills
}
pub fn skills_dir(&self) -> &PathBuf {
&self.config.skills_dir
}
pub async fn export_to_markdown(&self, skill_ids: Option<Vec<String>>) -> SkillResult<String> {
let skills = self.skills.read().await;
let selected_skills: Vec<&SkillDefinition> = match skill_ids {
Some(ids) => ids.iter().filter_map(|id| skills.get(id)).collect(),
None => skills.values().collect(),
};
let mut chunks = Vec::new();
for skill in selected_skills {
chunks.push(render_skill_markdown(skill)?);
}
Ok(chunks.join("\n\n"))
}
}
impl Default for SkillStore {
fn default() -> Self {
Self::new(SkillStoreConfig::default())
}
}
#[derive(Debug, Clone, Default)]
pub struct SkillUpdate {
pub name: Option<String>,
pub description: Option<String>,
pub prompt: Option<String>,
pub tool_refs: Option<Vec<String>>,
pub license: Option<String>,
pub compatibility: Option<String>,
pub metadata: Option<serde_json::Value>,
}
impl SkillUpdate {
pub fn new() -> Self {
Self::default()
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
self.prompt = Some(prompt.into());
self
}
pub fn with_tool_refs(mut self, tool_refs: Vec<String>) -> Self {
self.tool_refs = Some(tool_refs);
self
}
pub fn with_license(mut self, license: impl Into<String>) -> Self {
self.license = Some(license.into());
self
}
pub fn with_compatibility(mut self, compatibility: impl Into<String>) -> Self {
self.compatibility = Some(compatibility.into());
self
}
pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
self.metadata = Some(metadata);
self
}
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use tokio::fs;
use super::SkillStore;
use crate::skills::types::SkillStoreConfig;
async fn write_skill(
skills_root: &Path,
id: &str,
description: &str,
prompt: &str,
) -> std::io::Result<PathBuf> {
let skill_dir = skills_root.join(id);
fs::create_dir_all(&skill_dir).await?;
let skill_file = skill_dir.join("SKILL.md");
let content = format!(
"---\nname: {id}\ndescription: {description}\n---\n{prompt}\n",
id = id,
description = description,
prompt = prompt
);
fs::write(&skill_file, content).await?;
Ok(skill_dir)
}
#[tokio::test]
async fn load_markdown_skills() {
let directory = tempfile::tempdir().expect("tempdir");
let skills_dir = directory.path().join("skills");
fs::create_dir_all(&skills_dir).await.expect("create dir");
let content = r#"---
name: test-skill
description: A test skill
allowed-tools:
- read_file
---
Use this skill for testing.
"#;
let skill_dir = skills_dir.join("test-skill");
fs::create_dir_all(&skill_dir)
.await
.expect("create skill dir");
let skill_file = skill_dir.join("SKILL.md");
fs::write(&skill_file, content).await.expect("write");
let config = SkillStoreConfig {
skills_dir,
..Default::default()
};
let store = SkillStore::new(config);
store.initialize().await.expect("initialize");
let skills = store.list_skills(None, false).await;
assert!(skills.iter().any(|skill| skill.id == "test-skill"));
assert!(skills.iter().any(|skill| skill.id == "skill-creator"));
}
#[tokio::test]
async fn create_builtin_skills_when_empty() {
let directory = tempfile::tempdir().expect("tempdir");
let config = SkillStoreConfig {
skills_dir: directory.path().join("skills"),
..Default::default()
};
let store = SkillStore::new(config);
store.initialize().await.expect("initialize");
let skills = store.list_skills(None, false).await;
assert!(skills.iter().any(|skill| skill.id == "skill-creator"));
}
#[tokio::test]
async fn project_skill_overrides_global_skill() {
let directory = tempfile::tempdir().expect("tempdir");
let data_dir = directory.path().join("data");
let workspace_dir = directory.path().join("workspace");
let global_skills_dir = data_dir.join("skills");
let project_skills_dir = workspace_dir.join(".bamboo").join("skills");
fs::create_dir_all(&global_skills_dir)
.await
.expect("create global skills dir");
fs::create_dir_all(&project_skills_dir)
.await
.expect("create project skills dir");
write_skill(
&global_skills_dir,
"override-skill",
"global version",
"Global prompt",
)
.await
.expect("write global skill");
let project_skill_root = write_skill(
&project_skills_dir,
"override-skill",
"project version",
"Project prompt",
)
.await
.expect("write project skill");
let config = SkillStoreConfig {
skills_dir: global_skills_dir,
project_dir: Some(workspace_dir),
active_mode: None,
};
let store = SkillStore::new(config);
store.initialize().await.expect("initialize");
let skill = store
.get_skill("override-skill")
.await
.expect("override skill must exist");
assert_eq!(skill.description, "project version");
let resolved_root = store
.get_skill_root("override-skill")
.await
.expect("skill root");
let resolved_root = fs::canonicalize(resolved_root)
.await
.expect("canonical resolved root");
let expected_root = fs::canonicalize(project_skill_root)
.await
.expect("canonical expected root");
assert_eq!(resolved_root, expected_root);
}
#[tokio::test]
async fn mode_specific_skill_overrides_generic_for_same_source() {
let directory = tempfile::tempdir().expect("tempdir");
let data_dir = directory.path().join("data");
let global_skills_dir = data_dir.join("skills");
let global_mode_skills_dir = data_dir.join("skills-code");
fs::create_dir_all(&global_skills_dir)
.await
.expect("create global skills dir");
fs::create_dir_all(&global_mode_skills_dir)
.await
.expect("create global mode skills dir");
write_skill(
&global_skills_dir,
"mode-target-skill",
"generic version",
"Generic prompt",
)
.await
.expect("write generic skill");
write_skill(
&global_mode_skills_dir,
"mode-target-skill",
"mode version",
"Mode prompt",
)
.await
.expect("write mode skill");
let config = SkillStoreConfig {
skills_dir: global_skills_dir,
project_dir: None,
active_mode: Some("code".to_string()),
};
let store = SkillStore::new(config);
store.initialize().await.expect("initialize");
let skill = store
.get_skill("mode-target-skill")
.await
.expect("mode-target-skill must exist");
assert_eq!(skill.description, "mode version");
}
#[tokio::test]
async fn mode_specific_skill_is_ignored_without_active_mode() {
let directory = tempfile::tempdir().expect("tempdir");
let data_dir = directory.path().join("data");
let global_skills_dir = data_dir.join("skills");
let global_mode_skills_dir = data_dir.join("skills-code");
fs::create_dir_all(&global_skills_dir)
.await
.expect("create global skills dir");
fs::create_dir_all(&global_mode_skills_dir)
.await
.expect("create global mode skills dir");
write_skill(
&global_skills_dir,
"mode-target-skill",
"generic version",
"Generic prompt",
)
.await
.expect("write generic skill");
write_skill(
&global_mode_skills_dir,
"mode-target-skill",
"mode version",
"Mode prompt",
)
.await
.expect("write mode skill");
let config = SkillStoreConfig {
skills_dir: global_skills_dir,
project_dir: None,
active_mode: None,
};
let store = SkillStore::new(config);
store.initialize().await.expect("initialize");
let skill = store
.get_skill("mode-target-skill")
.await
.expect("mode-target-skill must exist");
assert_eq!(skill.description, "generic version");
}
#[tokio::test]
async fn get_skill_for_mode_overrides_cached_generic_selection() {
let directory = tempfile::tempdir().expect("tempdir");
let data_dir = directory.path().join("data");
let global_skills_dir = data_dir.join("skills");
let global_mode_skills_dir = data_dir.join("skills-code");
fs::create_dir_all(&global_skills_dir)
.await
.expect("create global skills dir");
fs::create_dir_all(&global_mode_skills_dir)
.await
.expect("create global mode skills dir");
write_skill(
&global_skills_dir,
"mode-target-skill",
"generic version",
"Generic prompt",
)
.await
.expect("write generic skill");
write_skill(
&global_mode_skills_dir,
"mode-target-skill",
"mode version",
"Mode prompt",
)
.await
.expect("write mode skill");
let config = SkillStoreConfig {
skills_dir: global_skills_dir,
project_dir: None,
active_mode: None,
};
let store = SkillStore::new(config);
store.initialize().await.expect("initialize");
let generic = store
.get_skill("mode-target-skill")
.await
.expect("generic skill exists");
assert_eq!(generic.description, "generic version");
let mode_specific = store
.get_skill_for_mode("mode-target-skill", Some("code"))
.await
.expect("mode-specific skill exists");
assert_eq!(mode_specific.description, "mode version");
}
}