//! Load builtin skills from `skills/**/*.md` into `skills` + //! `skill_versions` at server boot. Slice 3.5c of the missions //! consolidation. //! //! Frontmatter shape (YAML between `---` fences): //! name: //! description: //! when_to_use: //! tags: [foundation, rust, ...] //! //! The body is the rest of the file. Both are upserted idempotently: //! `skills_catalog::upsert_builtin` bumps the version + appends to //! `skill_versions` ONLY when the body actually changes. use serde::Deserialize; use sha2::{Digest, Sha256}; use sqlx::PgPool; use std::path::PathBuf; use cm_db::repo::skills_catalog::{upsert_builtin, UpsertBuiltinSkill}; #[derive(Debug, Deserialize)] struct Frontmatter { name: String, description: String, #[serde(default)] when_to_use: Option, #[serde(default)] tags: Vec, } fn skills_dir() -> PathBuf { if let Ok(d) = std::env::var("CLAWMATES_SKILLS_DIR") { return PathBuf::from(d); } let container = PathBuf::from("/etc/clawmates/skills"); if container.exists() { return container; } PathBuf::from("skills") } /// Deterministic id per builtin skill name — sha256 of a stable /// namespace + the name. Matches the pattern used by the team-template /// loader so ids are reproducible across boots. fn builtin_id(name: &str) -> uuid::Uuid { let mut h = Sha256::new(); h.update(b"clawmates.builtin.skill\x00"); h.update(name.as_bytes()); let d = h.finalize(); let mut bytes = [0u8; 16]; bytes.copy_from_slice(&d[..16]); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; uuid::Uuid::from_bytes(bytes) } /// Walk the skills tree + upsert every `*.md`. Returns the count of /// successful upserts. Failures are logged and skipped so a single /// broken skill can't block boot. pub async fn load_builtins(pool: &PgPool) -> usize { let dir = skills_dir(); let files = match walk_md(&dir) { Ok(v) => v, Err(e) => { eprintln!( "skills_loader: dir {} not readable: {e} — skipping builtin skills seed", dir.display() ); return 0; } }; let mut loaded = 0usize; for path in files { match load_one(pool, &path).await { Ok(name) => { loaded += 1; eprintln!("skills_loader: upserted builtin skill {name}"); } Err(e) => { eprintln!("skills_loader: failed to load {}: {e}", path.display()); } } } loaded } fn walk_md(root: &std::path::Path) -> std::io::Result> { let mut out = Vec::new(); fn recurse(p: &std::path::Path, out: &mut Vec) -> std::io::Result<()> { for entry in std::fs::read_dir(p)? { let entry = entry?; let path = entry.path(); if entry.file_type()?.is_dir() { recurse(&path, out)?; } else if path.extension().and_then(|s| s.to_str()) == Some("md") { out.push(path); } } Ok(()) } recurse(root, &mut out)?; out.sort(); Ok(out) } /// Parse the frontmatter + body out of one file and upsert. async fn load_one(pool: &PgPool, path: &std::path::Path) -> Result { let text = std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; let (frontmatter, body) = split_frontmatter(&text) .ok_or_else(|| format!("no --- frontmatter block in {}", path.display()))?; let fm: Frontmatter = serde_yaml::from_str(frontmatter) .map_err(|e| format!("parse frontmatter of {}: {e}", path.display()))?; let skill = UpsertBuiltinSkill { id: builtin_id(&fm.name), name: &fm.name, description: &fm.description, when_to_use: fm.when_to_use.as_deref(), tags: fm.tags.clone(), body, }; upsert_builtin(pool, skill) .await .map_err(|e| format!("upsert {}: {e}", fm.name))?; Ok(fm.name) } /// Extract the `---\n\n---\n` shape. Returns `(yaml, body)` /// or None if the file doesn't start with a frontmatter fence. fn split_frontmatter(text: &str) -> Option<(&str, &str)> { let mut rest = text.strip_prefix("---\n")?; // Some editors add a BOM; strip a leading whitespace/newline pair. if rest.starts_with('\r') { rest = rest.strip_prefix('\r').unwrap_or(rest); } let end = rest.find("\n---\n")?; let yaml = &rest[..end]; let body = &rest[end + "\n---\n".len()..]; Some((yaml, body.trim_start())) } #[cfg(test)] mod tests { use super::*; #[test] fn splits_frontmatter() { let src = "---\nname: foo\ndescription: bar\n---\n# body\n"; let (fm, body) = split_frontmatter(src).unwrap(); assert!(fm.contains("name: foo")); assert_eq!(body, "# body\n"); } #[test] fn no_frontmatter_returns_none() { assert!(split_frontmatter("# plain md\n").is_none()); } #[test] fn builtin_id_stable() { assert_eq!( builtin_id("workspace-repo-commit-protocol"), builtin_id("workspace-repo-commit-protocol") ); assert_ne!( builtin_id("workspace-repo-commit-protocol"), builtin_id("other-skill") ); } }