bamboo_agent/agent/skill/store/mod.rs
1//! Skill store with in-memory cache and markdown persistence.
2//!
3//! This module provides the central storage and management system for skills.
4//! Skills are loaded from Markdown files on disk and cached in memory for
5//! fast access during agent execution.
6//!
7//! # Architecture
8//!
9//! The skill store uses a dual-layer architecture:
10//! 1. **Disk Storage**: Skills are persisted as Markdown files in the skills directory
11//! 2. **In-Memory Cache**: Loaded skills are cached in a `RwLock<HashMap>` for fast access
12//!
13//! # Skill Discovery
14//!
15//! On initialization, the store:
16//! 1. Scans the skills directory for `SKILL.md` files
17//! 2. Parses frontmatter and content for each skill
18//! 3. Loads skills into the in-memory cache
19//! 4. Creates built-in skills if the directory is empty
20//!
21//! # Read-Only Design
22//!
23//! Skills are designed to be edited as Markdown files directly, not through
24//! the API. All modification methods return `SkillError::ReadOnly`.
25//!
26//! # Example
27//!
28//! ```no_run
29//! use bamboo_agent::skill::{SkillStore, SkillStoreConfig};
30//! use std::path::PathBuf;
31//!
32//! #[tokio::main]
33//! async fn main() {
34//! let config = SkillStoreConfig {
35//! skills_dir: PathBuf::from("./skills"),
36//! };
37//!
38//! let store = SkillStore::new(config);
39//! store.initialize().await.expect("Failed to initialize");
40//!
41//! // List all skills
42//! let skills = store.list_skills(None, false).await;
43//! for skill in skills {
44//! println!("{}: {}", skill.name, skill.description);
45//! }
46//! }
47//! ```
48
49pub mod builtin;
50pub mod parser;
51pub mod storage;
52
53use std::collections::HashMap;
54use std::path::PathBuf;
55
56use log::info;
57use tokio::sync::RwLock;
58
59use crate::agent::skill::store::builtin::{create_builtin_skills, get_builtin_scripts};
60use crate::agent::skill::store::parser::render_skill_markdown;
61use crate::agent::skill::store::storage::{
62 ensure_skills_dir, load_skills_from_dir, skill_path, write_skill_file,
63};
64use crate::agent::skill::types::{
65 SkillDefinition, SkillError, SkillFilter, SkillId, SkillResult, SkillStoreConfig,
66 SkillVisibility,
67};
68
69/// Persistent storage for skills with in-memory caching.
70///
71/// Manages a collection of skills loaded from Markdown files on disk.
72/// Uses a `RwLock<HashMap>` for thread-safe concurrent access.
73///
74/// # Thread Safety
75///
76/// All operations use async/await with `RwLock` to allow multiple readers
77/// or a single writer, ensuring safe concurrent access from multiple tasks.
78///
79/// # Example
80///
81/// ```rust,ignore
82/// let store = SkillStore::new(SkillStoreConfig::default());
83/// store.initialize().await?;
84///
85/// // Get a specific skill
86/// let skill = store.get_skill("my-skill").await?;
87/// println!("Skill: {}", skill.name);
88/// ```
89pub struct SkillStore {
90 /// In-memory cache of loaded skills, keyed by skill ID.
91 skills: RwLock<HashMap<SkillId, SkillDefinition>>,
92
93 /// Configuration specifying the skills directory path.
94 config: SkillStoreConfig,
95}
96
97impl SkillStore {
98 /// Create a new skill store with the given configuration.
99 ///
100 /// The store is created empty and must be initialized using [`initialize`](Self::initialize)
101 /// before it can be used.
102 ///
103 /// # Arguments
104 ///
105 /// * `config` - Configuration specifying the skills directory path.
106 ///
107 /// # Example
108 ///
109 /// ```no_run
110 /// use bamboo_agent::skill::{SkillStore, SkillStoreConfig};
111 /// use std::path::PathBuf;
112 ///
113 /// let config = SkillStoreConfig {
114 /// skills_dir: PathBuf::from("./skills"),
115 /// };
116 /// let store = SkillStore::new(config);
117 /// ```
118 pub fn new(config: SkillStoreConfig) -> Self {
119 Self {
120 skills: RwLock::new(HashMap::new()),
121 config,
122 }
123 }
124
125 /// Initialize the store, loading skills from disk.
126 ///
127 /// This method performs the following steps:
128 /// 1. Creates the skills directory if it doesn't exist.
129 /// 2. Attempts to load existing skills from disk.
130 /// 3. Creates built-in skills if no existing skills are found.
131 /// 4. Reloads skills into memory after initialization.
132 ///
133 /// # Returns
134 ///
135 /// `Ok(())` on successful initialization.
136 ///
137 /// # Errors
138 ///
139 /// Returns `SkillError` if:
140 /// - The skills directory cannot be created.
141 /// - Skill files cannot be read or parsed.
142 /// - Built-in skills cannot be written.
143 ///
144 /// # Example
145 ///
146 /// ```rust,ignore
147 /// let store = SkillStore::new(SkillStoreConfig::default());
148 /// store.initialize().await.expect("Failed to initialize");
149 /// ```
150 pub async fn initialize(&self) -> SkillResult<()> {
151 info!("Initializing skill store...");
152 ensure_skills_dir(&self.config.skills_dir).await?;
153
154 let loaded = self.load().await?;
155 if loaded == 0 {
156 info!("No existing skills found, creating built-in skills");
157 self.create_builtin_skills().await?;
158 self.load().await?;
159 }
160
161 info!("Skill store initialized");
162 Ok(())
163 }
164
165 /// Load skills from disk into the in-memory cache.
166 ///
167 /// Scans the skills directory for all `SKILL.md` files, parses them,
168 /// and loads them into the internal HashMap cache.
169 ///
170 /// # Returns
171 ///
172 /// The number of skills successfully loaded.
173 ///
174 /// # Errors
175 ///
176 /// Returns `SkillError` if the skills directory cannot be read.
177 async fn load(&self) -> SkillResult<usize> {
178 let loaded = load_skills_from_dir(&self.config.skills_dir).await?;
179 let count = loaded.len();
180
181 let mut skills = self.skills.write().await;
182 *skills = loaded;
183
184 Ok(count)
185 }
186
187 /// Create built-in skills on disk.
188 ///
189 /// Generates default skills that ship with Bamboo (e.g., skill-creator).
190 /// For each built-in skill, this method:
191 /// 1. Checks if the skill already exists (skips if so).
192 /// 2. Writes the skill definition to disk.
193 /// 3. Extracts and writes any embedded scripts.
194 /// 4. Sets executable permissions on Unix systems.
195 ///
196 /// # Returns
197 ///
198 /// `Ok(())` on success.
199 ///
200 /// # Errors
201 ///
202 /// Returns `SkillError` if file operations fail.
203 async fn create_builtin_skills(&self) -> SkillResult<()> {
204 for skill in create_builtin_skills() {
205 let path = skill_path(&self.config.skills_dir, &skill.id);
206 if path.exists() {
207 continue;
208 }
209 write_skill_file(&self.config.skills_dir, &skill).await?;
210
211 // Write embedded scripts for builtin skills (e.g., skill-creator)
212 let scripts = get_builtin_scripts(&skill.id);
213 for (script_path, content) in scripts {
214 let full_path = self.config.skills_dir.join(&skill.id).join(script_path);
215 if let Some(parent) = full_path.parent() {
216 tokio::fs::create_dir_all(parent).await?;
217 }
218 tokio::fs::write(&full_path, content).await?;
219 // Make scripts executable on Unix
220 #[cfg(unix)]
221 {
222 use std::os::unix::fs::PermissionsExt;
223 let mut perms = tokio::fs::metadata(&full_path).await?.permissions();
224 perms.set_mode(0o755);
225 tokio::fs::set_permissions(&full_path, perms).await?;
226 }
227 }
228 }
229
230 Ok(())
231 }
232
233 /// Reload skills from disk into the in-memory cache.
234 ///
235 /// This is useful when skills have been modified on disk and you want
236 /// to pick up the changes without restarting the application.
237 ///
238 /// # Returns
239 ///
240 /// The number of skills loaded.
241 ///
242 /// # Example
243 ///
244 /// ```rust,ignore
245 /// // After editing a skill file externally
246 /// let count = store.reload().await?;
247 /// println!("Loaded {} skills", count);
248 /// ```
249 pub async fn reload(&self) -> SkillResult<usize> {
250 info!("Reloading skills from disk...");
251 self.load().await
252 }
253
254 /// List all skills with optional filtering.
255 ///
256 /// Returns a sorted list of skills matching the specified filter criteria.
257 /// Optionally refreshes the cache from disk before listing.
258 ///
259 /// # Arguments
260 ///
261 /// * `filter` - Optional filter criteria (by category, tags, visibility, etc.).
262 /// * `refresh` - If true, reload skills from disk before listing.
263 ///
264 /// # Returns
265 ///
266 /// A vector of matching skills, sorted alphabetically by name.
267 ///
268 /// # Example
269 ///
270 /// ```rust,ignore
271 /// // List all public skills, refreshing from disk
272 /// let filter = SkillFilter {
273 /// visibility: Some(SkillVisibility::Public),
274 /// ..Default::default()
275 /// };
276 /// let skills = store.list_skills(Some(filter), true).await;
277 /// ```
278 pub async fn list_skills(
279 &self,
280 filter: Option<SkillFilter>,
281 refresh: bool,
282 ) -> Vec<SkillDefinition> {
283 // Optionally reload from disk to pick up new/updated skills
284 if refresh {
285 if let Err(e) = self.reload().await {
286 log::warn!("Failed to reload skills: {}", e);
287 }
288 }
289
290 let skills = self.skills.read().await;
291
292 let mut result: Vec<SkillDefinition> = skills
293 .values()
294 .filter(|skill| match &filter {
295 Some(active_filter) => active_filter.matches(skill),
296 None => true,
297 })
298 .cloned()
299 .collect();
300
301 result.sort_by(|left, right| left.name.cmp(&right.name));
302 result
303 }
304
305 /// Get a single skill by its ID.
306 ///
307 /// Retrieves a skill from the in-memory cache by its unique identifier.
308 ///
309 /// # Arguments
310 ///
311 /// * `id` - The skill ID (e.g., "skill-creator").
312 ///
313 /// # Returns
314 ///
315 /// The matching `SkillDefinition` if found.
316 ///
317 /// # Errors
318 ///
319 /// Returns `SkillError::NotFound` if no skill matches the given ID.
320 ///
321 /// # Example
322 ///
323 /// ```rust,ignore
324 /// let skill = store.get_skill("my-skill").await?;
325 /// println!("Description: {}", skill.description);
326 /// ```
327 pub async fn get_skill(&self, id: &str) -> SkillResult<SkillDefinition> {
328 let skills = self.skills.read().await;
329 skills
330 .get(id)
331 .cloned()
332 .ok_or_else(|| SkillError::NotFound(id.to_string()))
333 }
334
335 /// Create a new skill (not supported - read-only mode).
336 ///
337 /// Skills must be created by writing Markdown files directly to the
338 /// skills directory. This method always returns an error.
339 ///
340 /// # Errors
341 ///
342 /// Always returns `SkillError::ReadOnly`.
343 pub async fn create_skill(&self, _skill: SkillDefinition) -> SkillResult<SkillDefinition> {
344 Err(SkillError::ReadOnly(
345 "Skills are read-only and must be edited as Markdown files".to_string(),
346 ))
347 }
348
349 /// Update an existing skill (not supported - read-only mode).
350 ///
351 /// Skills must be edited by modifying Markdown files directly.
352 /// This method always returns an error.
353 ///
354 /// # Errors
355 ///
356 /// Always returns `SkillError::ReadOnly`.
357 pub async fn update_skill(
358 &self,
359 _id: &str,
360 _updates: SkillUpdate,
361 ) -> SkillResult<SkillDefinition> {
362 Err(SkillError::ReadOnly(
363 "Skills are read-only and must be edited as Markdown files".to_string(),
364 ))
365 }
366
367 /// Delete a skill (not supported - read-only mode).
368 ///
369 /// Skills must be deleted by removing their Markdown files directly.
370 /// This method always returns an error.
371 ///
372 /// # Errors
373 ///
374 /// Always returns `SkillError::ReadOnly`.
375 pub async fn delete_skill(&self, _id: &str) -> SkillResult<()> {
376 Err(SkillError::ReadOnly(
377 "Skills are read-only and must be edited as Markdown files".to_string(),
378 ))
379 }
380
381 /// Enable a skill globally (not supported - read-only mode).
382 ///
383 /// Skill visibility must be configured in the Markdown file's frontmatter.
384 /// This method always returns an error.
385 ///
386 /// # Errors
387 ///
388 /// Always returns `SkillError::ReadOnly`.
389 pub async fn enable_skill_global(&self, _id: &str) -> SkillResult<()> {
390 Err(SkillError::ReadOnly(
391 "Skills are read-only and must be edited as Markdown files".to_string(),
392 ))
393 }
394
395 /// Disable a skill globally (not supported - read-only mode).
396 ///
397 /// Skill visibility must be configured in the Markdown file's frontmatter.
398 /// This method always returns an error.
399 ///
400 /// # Errors
401 ///
402 /// Always returns `SkillError::ReadOnly`.
403 pub async fn disable_skill_global(&self, _id: &str) -> SkillResult<()> {
404 Err(SkillError::ReadOnly(
405 "Skills are read-only and must be edited as Markdown files".to_string(),
406 ))
407 }
408
409 /// Enable a skill for a specific chat (not supported - read-only mode).
410 ///
411 /// Skill chat associations are managed externally, not through this API.
412 /// This method always returns an error.
413 ///
414 /// # Errors
415 ///
416 /// Always returns `SkillError::ReadOnly`.
417 pub async fn enable_skill_for_chat(&self, _skill_id: &str, _chat_id: &str) -> SkillResult<()> {
418 Err(SkillError::ReadOnly(
419 "Skills are read-only and must be edited as Markdown files".to_string(),
420 ))
421 }
422
423 /// Disable a skill for a specific chat (not supported - read-only mode).
424 ///
425 /// Skill chat associations are managed externally, not through this API.
426 /// This method always returns an error.
427 ///
428 /// # Errors
429 ///
430 /// Always returns `SkillError::ReadOnly`.
431 pub async fn disable_skill_for_chat(&self, _skill_id: &str, _chat_id: &str) -> SkillResult<()> {
432 Err(SkillError::ReadOnly(
433 "Skills are read-only and must be edited as Markdown files".to_string(),
434 ))
435 }
436
437 /// Get all skills from the cache.
438 ///
439 /// Returns all loaded skills, sorted alphabetically by name.
440 /// This is a convenience method equivalent to `list_skills(None, false)`.
441 ///
442 /// # Returns
443 ///
444 /// A vector of all skills in the store.
445 ///
446 /// # Example
447 ///
448 /// ```rust,ignore
449 /// let skills = store.get_all_skills().await;
450 /// println!("Total skills: {}", skills.len());
451 /// ```
452 pub async fn get_all_skills(&self) -> Vec<SkillDefinition> {
453 let mut skills: Vec<SkillDefinition> = self.skills.read().await.values().cloned().collect();
454 skills.sort_by(|left, right| left.name.cmp(&right.name));
455 skills
456 }
457
458 /// Get the path to the skills directory.
459 ///
460 /// Returns the configured directory where skill Markdown files are stored.
461 ///
462 /// # Returns
463 ///
464 /// Reference to the skills directory path.
465 pub fn skills_dir(&self) -> &PathBuf {
466 &self.config.skills_dir
467 }
468
469 /// Get all unique categories across all skills.
470 ///
471 /// Scans all loaded skills and extracts their category values,
472 /// returning a deduplicated and sorted list.
473 ///
474 /// # Returns
475 ///
476 /// A sorted vector of unique category names.
477 ///
478 /// # Example
479 ///
480 /// ```rust,ignore
481 /// let categories = store.get_categories().await;
482 /// for category in categories {
483 /// println!("Category: {}", category);
484 /// }
485 /// ```
486 pub async fn get_categories(&self) -> Vec<String> {
487 let mut categories: Vec<String> = self
488 .skills
489 .read()
490 .await
491 .values()
492 .map(|skill| skill.category.clone())
493 .collect::<std::collections::HashSet<_>>()
494 .into_iter()
495 .collect();
496 categories.sort();
497 categories
498 }
499
500 /// Get all unique tags across all skills.
501 ///
502 /// Scans all loaded skills and aggregates their tags,
503 /// returning a deduplicated and sorted list.
504 ///
505 /// # Returns
506 ///
507 /// A sorted vector of unique tag names.
508 ///
509 /// # Example
510 ///
511 /// ```rust,ignore
512 /// let tags = store.get_all_tags().await;
513 /// println!("Available tags: {:?}", tags);
514 /// ```
515 pub async fn get_all_tags(&self) -> Vec<String> {
516 let mut tags: Vec<String> = self
517 .skills
518 .read()
519 .await
520 .values()
521 .flat_map(|skill| skill.tags.clone())
522 .collect::<std::collections::HashSet<_>>()
523 .into_iter()
524 .collect();
525 tags.sort();
526 tags
527 }
528
529 /// Export skills to Markdown format.
530 ///
531 /// Renders one or more skills as Markdown documents with YAML frontmatter.
532 /// Useful for creating backups or sharing skills.
533 ///
534 /// # Arguments
535 ///
536 /// * `skill_ids` - Optional list of skill IDs to export.
537 /// If `None`, exports all skills.
538 ///
539 /// # Returns
540 ///
541 /// A Markdown string containing all exported skills, separated by blank lines.
542 ///
543 /// # Errors
544 ///
545 /// Returns `SkillError` if Markdown rendering fails.
546 ///
547 /// # Example
548 ///
549 /// ```rust,ignore
550 /// // Export specific skills
551 /// let markdown = store.export_to_markdown(
552 /// Some(vec!["skill-creator".to_string()])
553 /// ).await?;
554 /// println!("{}", markdown);
555 ///
556 /// // Export all skills
557 /// let all_markdown = store.export_to_markdown(None).await?;
558 /// ```
559 pub async fn export_to_markdown(&self, skill_ids: Option<Vec<String>>) -> SkillResult<String> {
560 let skills = self.skills.read().await;
561
562 let selected_skills: Vec<&SkillDefinition> = match skill_ids {
563 Some(ids) => ids.iter().filter_map(|id| skills.get(id)).collect(),
564 None => skills.values().collect(),
565 };
566
567 let mut chunks = Vec::new();
568 for skill in selected_skills {
569 chunks.push(render_skill_markdown(skill)?);
570 }
571
572 Ok(chunks.join("\n\n"))
573 }
574}
575
576impl Default for SkillStore {
577 fn default() -> Self {
578 Self::new(SkillStoreConfig::default())
579 }
580}
581
582/// Update fields for skill modification.
583///
584/// This struct is used to specify which fields of a skill should be updated.
585/// All fields are optional - only provided fields will be changed.
586///
587/// Note: This is currently not used as skills are read-only, but is kept
588/// for future API compatibility and documentation purposes.
589///
590/// # Example
591///
592/// ```ignore
593/// let update = SkillUpdate::new()
594/// .with_name("New Name")
595/// .with_description("Updated description")
596/// .with_tags(vec!["new-tag".to_string()]);
597/// ```
598#[derive(Debug, Clone, Default)]
599pub struct SkillUpdate {
600 /// New name for the skill.
601 pub name: Option<String>,
602
603 /// New description for the skill.
604 pub description: Option<String>,
605
606 /// New category for the skill.
607 pub category: Option<String>,
608
609 /// New list of tags for the skill.
610 pub tags: Option<Vec<String>>,
611
612 /// New prompt template for the skill.
613 pub prompt: Option<String>,
614
615 /// New list of tool references for the skill.
616 pub tool_refs: Option<Vec<String>>,
617
618 /// New list of workflow references for the skill.
619 pub workflow_refs: Option<Vec<String>>,
620
621 /// New visibility setting for the skill.
622 pub visibility: Option<SkillVisibility>,
623
624 /// New version string for the skill.
625 pub version: Option<String>,
626}
627
628impl SkillUpdate {
629 /// Create a new empty update struct.
630 ///
631 /// All fields will be `None`, indicating no changes.
632 pub fn new() -> Self {
633 Self::default()
634 }
635
636 /// Set the name field.
637 ///
638 /// # Arguments
639 ///
640 /// * `name` - The new name for the skill.
641 pub fn with_name(mut self, name: impl Into<String>) -> Self {
642 self.name = Some(name.into());
643 self
644 }
645
646 /// Set the description field.
647 ///
648 /// # Arguments
649 ///
650 /// * `description` - The new description for the skill.
651 pub fn with_description(mut self, description: impl Into<String>) -> Self {
652 self.description = Some(description.into());
653 self
654 }
655
656 /// Set the category field.
657 ///
658 /// # Arguments
659 ///
660 /// * `category` - The new category for the skill.
661 pub fn with_category(mut self, category: impl Into<String>) -> Self {
662 self.category = Some(category.into());
663 self
664 }
665
666 /// Set the tags field.
667 ///
668 /// # Arguments
669 ///
670 /// * `tags` - The new list of tags for the skill.
671 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
672 self.tags = Some(tags);
673 self
674 }
675
676 /// Set the prompt field.
677 ///
678 /// # Arguments
679 ///
680 /// * `prompt` - The new prompt template for the skill.
681 pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
682 self.prompt = Some(prompt.into());
683 self
684 }
685
686 /// Set the tool references field.
687 ///
688 /// # Arguments
689 ///
690 /// * `tool_refs` - The new list of tool references for the skill.
691 pub fn with_tool_refs(mut self, tool_refs: Vec<String>) -> Self {
692 self.tool_refs = Some(tool_refs);
693 self
694 }
695
696 /// Set the workflow references field.
697 ///
698 /// # Arguments
699 ///
700 /// * `workflow_refs` - The new list of workflow references for the skill.
701 pub fn with_workflow_refs(mut self, workflow_refs: Vec<String>) -> Self {
702 self.workflow_refs = Some(workflow_refs);
703 self
704 }
705
706 /// Set the visibility field.
707 ///
708 /// # Arguments
709 ///
710 /// * `visibility` - The new visibility setting for the skill.
711 pub fn with_visibility(mut self, visibility: SkillVisibility) -> Self {
712 self.visibility = Some(visibility);
713 self
714 }
715
716 /// Set the version field.
717 ///
718 /// # Arguments
719 ///
720 /// * `version` - The new version string for the skill.
721 pub fn with_version(mut self, version: impl Into<String>) -> Self {
722 self.version = Some(version.into());
723 self
724 }
725}
726
727#[cfg(test)]
728mod tests {
729 use tokio::fs;
730
731 use super::SkillStore;
732 use crate::agent::skill::types::SkillStoreConfig;
733
734 #[tokio::test]
735 async fn load_markdown_skills() {
736 let directory = tempfile::tempdir().expect("tempdir");
737 let skills_dir = directory.path().join("skills");
738 fs::create_dir_all(&skills_dir).await.expect("create dir");
739
740 let content = r#"---
741id: test-skill
742name: Test Skill
743description: A test skill
744category: test
745tags:
746 - demo
747tool_refs:
748 - read_file
749workflow_refs: []
750visibility: public
751version: 1.0.0
752created_at: "2026-02-01T00:00:00Z"
753updated_at: "2026-02-01T00:00:00Z"
754---
755Use this skill for testing.
756"#;
757
758 let skill_dir = skills_dir.join("test-skill");
759 fs::create_dir_all(&skill_dir)
760 .await
761 .expect("create skill dir");
762 let skill_file = skill_dir.join("SKILL.md");
763 fs::write(&skill_file, content).await.expect("write");
764
765 let config = SkillStoreConfig { skills_dir };
766 let store = SkillStore::new(config);
767 store.initialize().await.expect("initialize");
768
769 let skills = store.list_skills(None, false).await;
770 assert_eq!(skills.len(), 1);
771 assert_eq!(skills[0].id, "test-skill");
772 }
773
774 #[tokio::test]
775 async fn create_builtin_skills_when_empty() {
776 let directory = tempfile::tempdir().expect("tempdir");
777 let config = SkillStoreConfig {
778 skills_dir: directory.path().join("skills"),
779 };
780 let store = SkillStore::new(config);
781 store.initialize().await.expect("initialize");
782
783 let skills = store.list_skills(None, false).await;
784 assert!(!skills.is_empty());
785 }
786}