1pub mod builtin;
51pub mod parser;
52pub mod storage;
53
54use std::collections::HashMap;
55use std::path::{Path, PathBuf};
56
57use tokio::sync::RwLock;
58use tracing::info;
59
60use crate::skills::store::builtin::load_builtin_skill_bundles;
61use crate::skills::store::parser::render_skill_markdown;
62use crate::skills::store::storage::{
63 ensure_skills_dir, load_skills_from_discovery_dirs, write_skill_file, SkillDirectorySource,
64 SkillDiscoveryDir,
65};
66use crate::skills::types::{
67 SkillDefinition, SkillError, SkillFilter, SkillId, SkillResult, SkillStoreConfig,
68};
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71struct SkillCandidateMeta {
72 source: SkillDirectorySource,
73 mode: Option<String>,
74}
75
76pub struct SkillStore {
97 skills: RwLock<HashMap<SkillId, SkillDefinition>>,
99 skill_roots: RwLock<HashMap<SkillId, PathBuf>>,
101
102 config: SkillStoreConfig,
104}
105
106impl SkillStore {
107 fn normalize_mode(raw_mode: Option<&str>) -> Option<String> {
108 let raw = raw_mode?.trim();
109 if raw.is_empty() {
110 return None;
111 }
112
113 let normalized = raw.to_ascii_lowercase();
114 if !normalized.chars().all(|character| {
115 character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
116 }) {
117 tracing::warn!(
118 "Ignoring invalid skill mode '{}' (allowed: lowercase letters, digits, hyphen)",
119 raw
120 );
121 return None;
122 }
123
124 Some(normalized)
125 }
126
127 fn effective_mode(&self, mode_override: Option<&str>) -> Option<String> {
128 Self::normalize_mode(mode_override)
129 .or_else(|| Self::normalize_mode(self.config.active_mode.as_deref()))
130 }
131
132 fn sibling_skills_mode_dir(base_skills_dir: &Path, mode: &str) -> PathBuf {
133 let parent = base_skills_dir
134 .parent()
135 .map(Path::to_path_buf)
136 .unwrap_or_else(|| PathBuf::from("."));
137 parent.join(format!("skills-{mode}"))
138 }
139
140 fn project_skills_dir(project_dir: &Path) -> PathBuf {
141 project_dir.join(".bamboo").join("skills")
142 }
143
144 fn project_skills_mode_dir(project_dir: &Path, mode: &str) -> PathBuf {
145 project_dir.join(".bamboo").join(format!("skills-{mode}"))
146 }
147
148 fn discovery_dirs_for_mode(&self, mode_override: Option<&str>) -> Vec<SkillDiscoveryDir> {
149 let mut dirs = Vec::new();
150 let active_mode = self.effective_mode(mode_override);
151
152 dirs.push(SkillDiscoveryDir {
153 dir: self.config.skills_dir.clone(),
154 source: SkillDirectorySource::Global,
155 mode: None,
156 });
157 if let Some(mode) = active_mode.as_ref() {
158 dirs.push(SkillDiscoveryDir {
159 dir: Self::sibling_skills_mode_dir(&self.config.skills_dir, mode),
160 source: SkillDirectorySource::Global,
161 mode: Some(mode.clone()),
162 });
163 }
164
165 if let Some(project_dir) = self.config.project_dir.as_ref() {
166 dirs.push(SkillDiscoveryDir {
167 dir: Self::project_skills_dir(project_dir),
168 source: SkillDirectorySource::Project,
169 mode: None,
170 });
171 if let Some(mode) = active_mode.as_ref() {
172 dirs.push(SkillDiscoveryDir {
173 dir: Self::project_skills_mode_dir(project_dir, mode),
174 source: SkillDirectorySource::Project,
175 mode: Some(mode.clone()),
176 });
177 }
178 }
179
180 dirs
181 }
182
183 fn resolve_from_loaded_records(
184 loaded_records: Vec<crate::skills::store::storage::LoadedSkillRecord>,
185 ) -> (HashMap<SkillId, SkillDefinition>, HashMap<SkillId, PathBuf>) {
186 let mut resolved_skills: HashMap<SkillId, SkillDefinition> = HashMap::new();
187 let mut resolved_roots: HashMap<SkillId, PathBuf> = HashMap::new();
188 let mut resolved_meta: HashMap<SkillId, SkillCandidateMeta> = HashMap::new();
189
190 for record in loaded_records {
191 let skill_id = record.skill.id.clone();
192 let candidate_meta = SkillCandidateMeta {
193 source: record.source,
194 mode: record.mode.clone(),
195 };
196
197 let should_replace = resolved_meta
198 .get(&skill_id)
199 .is_some_and(|existing| Self::should_override_skill(existing, &candidate_meta));
200 let should_keep_existing = resolved_meta.contains_key(&skill_id) && !should_replace;
201
202 if should_keep_existing {
203 tracing::debug!(
204 "Keeping existing skill '{}' over candidate from {:?} (mode={})",
205 skill_id,
206 candidate_meta.source,
207 candidate_meta.mode.as_deref().unwrap_or("generic")
208 );
209 continue;
210 }
211
212 if should_replace {
213 tracing::info!(
214 "Skill '{}' overridden by {:?} (mode={})",
215 skill_id,
216 candidate_meta.source,
217 candidate_meta.mode.as_deref().unwrap_or("generic")
218 );
219 }
220
221 resolved_skills.insert(skill_id.clone(), record.skill);
222 resolved_roots.insert(skill_id.clone(), record.skill_root);
223 resolved_meta.insert(skill_id, candidate_meta);
224 }
225
226 (resolved_skills, resolved_roots)
227 }
228
229 async fn resolve_skills_maps_for_mode(
230 &self,
231 mode_override: Option<&str>,
232 ) -> SkillResult<(HashMap<SkillId, SkillDefinition>, HashMap<SkillId, PathBuf>)> {
233 let loaded_records =
234 load_skills_from_discovery_dirs(&self.discovery_dirs_for_mode(mode_override)).await?;
235 Ok(Self::resolve_from_loaded_records(loaded_records))
236 }
237
238 fn should_override_skill(
239 existing: &SkillCandidateMeta,
240 candidate: &SkillCandidateMeta,
241 ) -> bool {
242 match (existing.source, candidate.source) {
243 (SkillDirectorySource::Global, SkillDirectorySource::Project) => return true,
244 (SkillDirectorySource::Project, SkillDirectorySource::Global) => return false,
245 _ => {}
246 }
247
248 match (existing.mode.is_some(), candidate.mode.is_some()) {
249 (false, true) => true,
250 (true, false) => false,
251 _ => false,
252 }
253 }
254
255 pub fn new(config: SkillStoreConfig) -> Self {
277 Self {
278 skills: RwLock::new(HashMap::new()),
279 skill_roots: RwLock::new(HashMap::new()),
280 config,
281 }
282 }
283
284 pub async fn initialize(&self) -> SkillResult<()> {
309 info!("Initializing skill store...");
310 ensure_skills_dir(&self.config.skills_dir).await?;
311 self.create_builtin_skills().await?;
312 self.load().await?;
313
314 info!("Skill store initialized");
315 Ok(())
316 }
317
318 async fn load(&self) -> SkillResult<usize> {
331 let (resolved_skills, resolved_roots) = self.resolve_skills_maps_for_mode(None).await?;
332 let count = resolved_skills.len();
333 let mut skills = self.skills.write().await;
334 let mut roots = self.skill_roots.write().await;
335 *skills = resolved_skills;
336 *roots = resolved_roots;
337
338 Ok(count)
339 }
340
341 async fn create_builtin_skills(&self) -> SkillResult<()> {
358 for bundle in load_builtin_skill_bundles()? {
359 let skill_id = bundle.skill.id.clone();
360 write_skill_file(&self.config.skills_dir, &bundle.skill).await?;
361
362 for (relative_path, content) in bundle.files {
363 let full_path = self.config.skills_dir.join(&skill_id).join(&relative_path);
364 if let Some(parent) = full_path.parent() {
365 tokio::fs::create_dir_all(parent).await?;
366 }
367 tokio::fs::write(&full_path, content).await?;
368 #[cfg(unix)]
370 {
371 if relative_path.starts_with("scripts/") {
372 use std::os::unix::fs::PermissionsExt;
373 let mut perms = tokio::fs::metadata(&full_path).await?.permissions();
374 perms.set_mode(0o755);
375 tokio::fs::set_permissions(&full_path, perms).await?;
376 }
377 }
378 }
379 }
380
381 Ok(())
382 }
383
384 pub async fn reload(&self) -> SkillResult<usize> {
401 info!("Reloading skills from disk...");
402 self.load().await
403 }
404
405 pub async fn list_skills(
427 &self,
428 filter: Option<SkillFilter>,
429 refresh: bool,
430 ) -> Vec<SkillDefinition> {
431 if refresh {
433 if let Err(e) = self.reload().await {
434 tracing::warn!("Failed to reload skills: {}", e);
435 }
436 }
437
438 let skills = self.skills.read().await;
439
440 let mut result: Vec<SkillDefinition> = skills
441 .values()
442 .filter(|skill| match &filter {
443 Some(active_filter) => active_filter.matches(skill),
444 None => true,
445 })
446 .cloned()
447 .collect();
448
449 result.sort_by_key(|s| s.name.clone());
450 result
451 }
452
453 pub async fn list_skills_for_mode(
455 &self,
456 filter: Option<SkillFilter>,
457 mode_override: Option<&str>,
458 ) -> Vec<SkillDefinition> {
459 let (skills, _) = match self.resolve_skills_maps_for_mode(mode_override).await {
460 Ok(maps) => maps,
461 Err(error) => {
462 tracing::warn!(
463 "Failed to resolve skills for mode {:?}: {}",
464 mode_override,
465 error
466 );
467 return Vec::new();
468 }
469 };
470
471 let mut result: Vec<SkillDefinition> = skills
472 .values()
473 .filter(|skill| match &filter {
474 Some(active_filter) => active_filter.matches(skill),
475 None => true,
476 })
477 .cloned()
478 .collect();
479 result.sort_by_key(|s| s.name.clone());
480 result
481 }
482
483 pub async fn get_skill(&self, id: &str) -> SkillResult<SkillDefinition> {
506 let skills = self.skills.read().await;
507 skills
508 .get(id)
509 .cloned()
510 .ok_or_else(|| SkillError::NotFound(id.to_string()))
511 }
512
513 pub async fn get_skill_for_mode(
515 &self,
516 id: &str,
517 mode_override: Option<&str>,
518 ) -> SkillResult<SkillDefinition> {
519 if mode_override.is_none() {
520 return self.get_skill(id).await;
521 }
522
523 let (skills, _) = self.resolve_skills_maps_for_mode(mode_override).await?;
524 skills
525 .get(id)
526 .cloned()
527 .ok_or_else(|| SkillError::NotFound(id.to_string()))
528 }
529
530 pub async fn get_skill_root(&self, id: &str) -> SkillResult<PathBuf> {
532 let roots = self.skill_roots.read().await;
533 roots
534 .get(id)
535 .cloned()
536 .ok_or_else(|| SkillError::NotFound(id.to_string()))
537 }
538
539 pub async fn get_skill_root_for_mode(
541 &self,
542 id: &str,
543 mode_override: Option<&str>,
544 ) -> SkillResult<PathBuf> {
545 if mode_override.is_none() {
546 return self.get_skill_root(id).await;
547 }
548
549 let (_, roots) = self.resolve_skills_maps_for_mode(mode_override).await?;
550 roots
551 .get(id)
552 .cloned()
553 .ok_or_else(|| SkillError::NotFound(id.to_string()))
554 }
555
556 pub async fn create_skill(&self, _skill: SkillDefinition) -> SkillResult<SkillDefinition> {
565 Err(SkillError::ReadOnly(
566 "Skills are read-only and must be edited as Markdown files".to_string(),
567 ))
568 }
569
570 pub async fn update_skill(
579 &self,
580 _id: &str,
581 _updates: SkillUpdate,
582 ) -> SkillResult<SkillDefinition> {
583 Err(SkillError::ReadOnly(
584 "Skills are read-only and must be edited as Markdown files".to_string(),
585 ))
586 }
587
588 pub async fn delete_skill(&self, _id: &str) -> SkillResult<()> {
597 Err(SkillError::ReadOnly(
598 "Skills are read-only and must be edited as Markdown files".to_string(),
599 ))
600 }
601
602 pub async fn enable_skill_global(&self, _id: &str) -> SkillResult<()> {
611 Err(SkillError::ReadOnly(
612 "Skills are read-only and must be edited as Markdown files".to_string(),
613 ))
614 }
615
616 pub async fn disable_skill_global(&self, _id: &str) -> SkillResult<()> {
625 Err(SkillError::ReadOnly(
626 "Skills are read-only and must be edited as Markdown files".to_string(),
627 ))
628 }
629
630 pub async fn enable_skill_for_chat(&self, _skill_id: &str, _chat_id: &str) -> SkillResult<()> {
639 Err(SkillError::ReadOnly(
640 "Skills are read-only and must be edited as Markdown files".to_string(),
641 ))
642 }
643
644 pub async fn disable_skill_for_chat(&self, _skill_id: &str, _chat_id: &str) -> SkillResult<()> {
653 Err(SkillError::ReadOnly(
654 "Skills are read-only and must be edited as Markdown files".to_string(),
655 ))
656 }
657
658 pub async fn get_all_skills(&self) -> Vec<SkillDefinition> {
674 let mut skills: Vec<SkillDefinition> = self.skills.read().await.values().cloned().collect();
675 skills.sort_by_key(|s| s.name.clone());
676 skills
677 }
678
679 pub fn skills_dir(&self) -> &PathBuf {
687 &self.config.skills_dir
688 }
689
690 pub async fn export_to_markdown(&self, skill_ids: Option<Vec<String>>) -> SkillResult<String> {
721 let skills = self.skills.read().await;
722
723 let selected_skills: Vec<&SkillDefinition> = match skill_ids {
724 Some(ids) => ids.iter().filter_map(|id| skills.get(id)).collect(),
725 None => skills.values().collect(),
726 };
727
728 let mut chunks = Vec::new();
729 for skill in selected_skills {
730 chunks.push(render_skill_markdown(skill)?);
731 }
732
733 Ok(chunks.join("\n\n"))
734 }
735}
736
737impl Default for SkillStore {
738 fn default() -> Self {
739 Self::new(SkillStoreConfig::default())
740 }
741}
742
743#[derive(Debug, Clone, Default)]
760pub struct SkillUpdate {
761 pub name: Option<String>,
763
764 pub description: Option<String>,
766
767 pub prompt: Option<String>,
769
770 pub tool_refs: Option<Vec<String>>,
772
773 pub license: Option<String>,
775
776 pub compatibility: Option<String>,
778
779 pub metadata: Option<serde_json::Value>,
781}
782
783impl SkillUpdate {
784 pub fn new() -> Self {
788 Self::default()
789 }
790
791 pub fn with_name(mut self, name: impl Into<String>) -> Self {
797 self.name = Some(name.into());
798 self
799 }
800
801 pub fn with_description(mut self, description: impl Into<String>) -> Self {
807 self.description = Some(description.into());
808 self
809 }
810
811 pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
817 self.prompt = Some(prompt.into());
818 self
819 }
820
821 pub fn with_tool_refs(mut self, tool_refs: Vec<String>) -> Self {
827 self.tool_refs = Some(tool_refs);
828 self
829 }
830
831 pub fn with_license(mut self, license: impl Into<String>) -> Self {
837 self.license = Some(license.into());
838 self
839 }
840
841 pub fn with_compatibility(mut self, compatibility: impl Into<String>) -> Self {
847 self.compatibility = Some(compatibility.into());
848 self
849 }
850
851 pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
857 self.metadata = Some(metadata);
858 self
859 }
860}
861
862#[cfg(test)]
863mod tests {
864 use std::path::{Path, PathBuf};
865
866 use tokio::fs;
867
868 use super::SkillStore;
869 use crate::skills::types::SkillStoreConfig;
870
871 async fn write_skill(
872 skills_root: &Path,
873 id: &str,
874 description: &str,
875 prompt: &str,
876 ) -> std::io::Result<PathBuf> {
877 let skill_dir = skills_root.join(id);
878 fs::create_dir_all(&skill_dir).await?;
879 let skill_file = skill_dir.join("SKILL.md");
880 let content = format!(
881 "---\nname: {id}\ndescription: {description}\n---\n{prompt}\n",
882 id = id,
883 description = description,
884 prompt = prompt
885 );
886 fs::write(&skill_file, content).await?;
887 Ok(skill_dir)
888 }
889
890 #[tokio::test]
891 async fn load_markdown_skills() {
892 let directory = tempfile::tempdir().expect("tempdir");
893 let skills_dir = directory.path().join("skills");
894 fs::create_dir_all(&skills_dir).await.expect("create dir");
895
896 let content = r#"---
897name: test-skill
898description: A test skill
899allowed-tools:
900 - read_file
901---
902Use this skill for testing.
903"#;
904
905 let skill_dir = skills_dir.join("test-skill");
906 fs::create_dir_all(&skill_dir)
907 .await
908 .expect("create skill dir");
909 let skill_file = skill_dir.join("SKILL.md");
910 fs::write(&skill_file, content).await.expect("write");
911
912 let config = SkillStoreConfig {
913 skills_dir,
914 ..Default::default()
915 };
916 let store = SkillStore::new(config);
917 store.initialize().await.expect("initialize");
918
919 let skills = store.list_skills(None, false).await;
920 assert!(skills.iter().any(|skill| skill.id == "test-skill"));
921 assert!(skills.iter().any(|skill| skill.id == "skill-creator"));
922 }
923
924 #[tokio::test]
925 async fn create_builtin_skills_when_empty() {
926 let directory = tempfile::tempdir().expect("tempdir");
927 let config = SkillStoreConfig {
928 skills_dir: directory.path().join("skills"),
929 ..Default::default()
930 };
931 let store = SkillStore::new(config);
932 store.initialize().await.expect("initialize");
933
934 let skills = store.list_skills(None, false).await;
935 assert!(skills.iter().any(|skill| skill.id == "skill-creator"));
936 }
937
938 #[tokio::test]
939 async fn project_skill_overrides_global_skill() {
940 let directory = tempfile::tempdir().expect("tempdir");
941 let data_dir = directory.path().join("data");
942 let workspace_dir = directory.path().join("workspace");
943 let global_skills_dir = data_dir.join("skills");
944 let project_skills_dir = workspace_dir.join(".bamboo").join("skills");
945
946 fs::create_dir_all(&global_skills_dir)
947 .await
948 .expect("create global skills dir");
949 fs::create_dir_all(&project_skills_dir)
950 .await
951 .expect("create project skills dir");
952
953 write_skill(
954 &global_skills_dir,
955 "override-skill",
956 "global version",
957 "Global prompt",
958 )
959 .await
960 .expect("write global skill");
961 let project_skill_root = write_skill(
962 &project_skills_dir,
963 "override-skill",
964 "project version",
965 "Project prompt",
966 )
967 .await
968 .expect("write project skill");
969
970 let config = SkillStoreConfig {
971 skills_dir: global_skills_dir,
972 project_dir: Some(workspace_dir),
973 active_mode: None,
974 };
975 let store = SkillStore::new(config);
976 store.initialize().await.expect("initialize");
977
978 let skill = store
979 .get_skill("override-skill")
980 .await
981 .expect("override skill must exist");
982 assert_eq!(skill.description, "project version");
983
984 let resolved_root = store
985 .get_skill_root("override-skill")
986 .await
987 .expect("skill root");
988 let resolved_root = fs::canonicalize(resolved_root)
989 .await
990 .expect("canonical resolved root");
991 let expected_root = fs::canonicalize(project_skill_root)
992 .await
993 .expect("canonical expected root");
994 assert_eq!(resolved_root, expected_root);
995 }
996
997 #[tokio::test]
998 async fn mode_specific_skill_overrides_generic_for_same_source() {
999 let directory = tempfile::tempdir().expect("tempdir");
1000 let data_dir = directory.path().join("data");
1001 let global_skills_dir = data_dir.join("skills");
1002 let global_mode_skills_dir = data_dir.join("skills-code");
1003
1004 fs::create_dir_all(&global_skills_dir)
1005 .await
1006 .expect("create global skills dir");
1007 fs::create_dir_all(&global_mode_skills_dir)
1008 .await
1009 .expect("create global mode skills dir");
1010
1011 write_skill(
1012 &global_skills_dir,
1013 "mode-target-skill",
1014 "generic version",
1015 "Generic prompt",
1016 )
1017 .await
1018 .expect("write generic skill");
1019 write_skill(
1020 &global_mode_skills_dir,
1021 "mode-target-skill",
1022 "mode version",
1023 "Mode prompt",
1024 )
1025 .await
1026 .expect("write mode skill");
1027
1028 let config = SkillStoreConfig {
1029 skills_dir: global_skills_dir,
1030 project_dir: None,
1031 active_mode: Some("code".to_string()),
1032 };
1033 let store = SkillStore::new(config);
1034 store.initialize().await.expect("initialize");
1035
1036 let skill = store
1037 .get_skill("mode-target-skill")
1038 .await
1039 .expect("mode-target-skill must exist");
1040 assert_eq!(skill.description, "mode version");
1041 }
1042
1043 #[tokio::test]
1044 async fn mode_specific_skill_is_ignored_without_active_mode() {
1045 let directory = tempfile::tempdir().expect("tempdir");
1046 let data_dir = directory.path().join("data");
1047 let global_skills_dir = data_dir.join("skills");
1048 let global_mode_skills_dir = data_dir.join("skills-code");
1049
1050 fs::create_dir_all(&global_skills_dir)
1051 .await
1052 .expect("create global skills dir");
1053 fs::create_dir_all(&global_mode_skills_dir)
1054 .await
1055 .expect("create global mode skills dir");
1056
1057 write_skill(
1058 &global_skills_dir,
1059 "mode-target-skill",
1060 "generic version",
1061 "Generic prompt",
1062 )
1063 .await
1064 .expect("write generic skill");
1065 write_skill(
1066 &global_mode_skills_dir,
1067 "mode-target-skill",
1068 "mode version",
1069 "Mode prompt",
1070 )
1071 .await
1072 .expect("write mode skill");
1073
1074 let config = SkillStoreConfig {
1075 skills_dir: global_skills_dir,
1076 project_dir: None,
1077 active_mode: None,
1078 };
1079 let store = SkillStore::new(config);
1080 store.initialize().await.expect("initialize");
1081
1082 let skill = store
1083 .get_skill("mode-target-skill")
1084 .await
1085 .expect("mode-target-skill must exist");
1086 assert_eq!(skill.description, "generic version");
1087 }
1088
1089 #[tokio::test]
1090 async fn get_skill_for_mode_overrides_cached_generic_selection() {
1091 let directory = tempfile::tempdir().expect("tempdir");
1092 let data_dir = directory.path().join("data");
1093 let global_skills_dir = data_dir.join("skills");
1094 let global_mode_skills_dir = data_dir.join("skills-code");
1095
1096 fs::create_dir_all(&global_skills_dir)
1097 .await
1098 .expect("create global skills dir");
1099 fs::create_dir_all(&global_mode_skills_dir)
1100 .await
1101 .expect("create global mode skills dir");
1102
1103 write_skill(
1104 &global_skills_dir,
1105 "mode-target-skill",
1106 "generic version",
1107 "Generic prompt",
1108 )
1109 .await
1110 .expect("write generic skill");
1111 write_skill(
1112 &global_mode_skills_dir,
1113 "mode-target-skill",
1114 "mode version",
1115 "Mode prompt",
1116 )
1117 .await
1118 .expect("write mode skill");
1119
1120 let config = SkillStoreConfig {
1121 skills_dir: global_skills_dir,
1122 project_dir: None,
1123 active_mode: None,
1124 };
1125 let store = SkillStore::new(config);
1126 store.initialize().await.expect("initialize");
1127
1128 let generic = store
1130 .get_skill("mode-target-skill")
1131 .await
1132 .expect("generic skill exists");
1133 assert_eq!(generic.description, "generic version");
1134
1135 let mode_specific = store
1137 .get_skill_for_mode("mode-target-skill", Some("code"))
1138 .await
1139 .expect("mode-specific skill exists");
1140 assert_eq!(mode_specific.description, "mode version");
1141 }
1142}