Skip to main content

miku/
skills.rs

1use anyhow::{Result, anyhow, bail};
2use std::collections::HashMap;
3use std::path::Path;
4use tracing::warn;
5use walkdir::WalkDir;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum SelectionMode {
9    All,
10    Select(Vec<String>),
11    Interactive,
12}
13
14pub fn parse_frontmatter(content: &str) -> Option<(Option<String>, Option<String>)> {
15    let mut lines = content.lines();
16    let first = lines.next()?.trim();
17    if first != "---" {
18        return None;
19    }
20
21    let mut name = None;
22    let mut description = None;
23    let mut found_end = false;
24
25    for line in lines {
26        let line = line.trim();
27        if line == "---" {
28            found_end = true;
29            break;
30        }
31        if let Some((k, v)) = line.split_once(':') {
32            let key = k.trim();
33            let val = v.trim().trim_matches('"').trim_matches('\'').to_string();
34            if key == "name" {
35                name = Some(val);
36            } else if key == "description" {
37                description = Some(val);
38            }
39        }
40    }
41
42    if found_end {
43        return Some((name, description));
44    }
45    None
46}
47
48pub fn derive_source_slug(url: &str) -> String {
49    let mut trimmed = url.trim();
50    if trimmed.ends_with(".git") {
51        trimmed = &trimmed[..trimmed.len() - 4];
52    }
53
54    // Trim trailing slashes
55    trimmed = trimmed.trim_end_matches('/');
56
57    // 1. https/http URLs
58    if let Some(pos) = trimmed.find("://") {
59        let path = &trimmed[pos + 3..];
60        if let Some(slash_pos) = path.find('/') {
61            let parts: Vec<&str> = path[slash_pos + 1..].split('/').collect();
62            if parts.len() >= 2 {
63                return format!("{}-{}", parts[0], parts[1]);
64            }
65        }
66    }
67
68    // 2. SSH URL git@github.com:owner/repo
69    if let Some(colon_pos) = trimmed.find(':') {
70        let path = &trimmed[colon_pos + 1..];
71        let parts: Vec<&str> = path.split('/').collect();
72        if parts.len() >= 2 {
73            return format!("{}-{}", parts[0], parts[1]);
74        }
75    }
76
77    // 3. Fallback: take last two components
78    let parts: Vec<&str> = trimmed.split('/').collect();
79    if parts.len() >= 2 {
80        let len = parts.len();
81        return format!("{}-{}", parts[len - 2], parts[len - 1]);
82    }
83
84    crate::normalize::normalize_key(url)
85}
86
87pub fn resolve_selection(
88    skills: &[(String, String)],
89    mode: SelectionMode,
90    is_tty: bool,
91) -> Result<Vec<String>> {
92    match mode {
93        SelectionMode::All => Ok(skills.iter().map(|(n, _)| n.clone()).collect()),
94        SelectionMode::Select(names) => {
95            let mut selected = Vec::new();
96            for name in names {
97                if skills.iter().any(|(n, _)| n == &name) {
98                    selected.push(name);
99                } else {
100                    bail!("Skill '{}' not found in source", name);
101                }
102            }
103            Ok(selected)
104        }
105        SelectionMode::Interactive => {
106            if !is_tty {
107                bail!("Cannot resolve selection interactively: not a TTY. Use --all or --select");
108            }
109            use std::io::{self, Write};
110            println!("Available skills:");
111            for (i, (name, desc)) in skills.iter().enumerate() {
112                println!("  [{}] {} - {}", i + 1, name, desc);
113            }
114            print!("Enter the numbers of the skills to install (comma-separated, e.g. 1, 3): ");
115            io::stdout().flush()?;
116            let mut input = String::new();
117            io::stdin().read_line(&mut input)?;
118            let mut selected = Vec::new();
119            for part in input.split(|c: char| c == ',' || c.is_whitespace()) {
120                let part = part.trim();
121                if part.is_empty() {
122                    continue;
123                }
124                if let Ok(idx) = part.parse::<usize>() {
125                    if idx > 0 && idx <= skills.len() {
126                        selected.push(skills[idx - 1].0.clone());
127                    } else {
128                        bail!("Invalid skill index: {}", idx);
129                    }
130                } else {
131                    bail!("Invalid input: {}", part);
132                }
133            }
134            if selected.is_empty() {
135                bail!("No skills selected");
136            }
137            Ok(selected)
138        }
139    }
140}
141
142pub async fn install_skills_from_dir<
143    #[cfg(feature = "documents")] E: crate::embed::EmbeddingProvider,
144>(
145    conn: &libsql::Connection,
146    dir_path: &Path,
147    source: &str,
148    version: &str,
149    mode: SelectionMode,
150    is_tty: bool,
151    prune: bool,
152    #[cfg(feature = "documents")] vector_ctx: Option<(&crate::vector::VectorStore, &E)>,
153) -> Result<()> {
154    let mut parsed_skills = Vec::new();
155    let mut skill_contents = HashMap::new();
156
157    for entry in WalkDir::new(dir_path)
158        .into_iter()
159        .filter_map(|e| e.ok())
160        .filter(|e| e.path().is_file() && e.path().extension().is_some_and(|ext| ext == "md"))
161    {
162        let mut content = std::fs::read_to_string(entry.path())?;
163        content = content.replace("\r\n", "\n");
164        if let Some((parsed_name, parsed_desc)) = parse_frontmatter(&content) {
165            let name = match parsed_name {
166                Some(n) => n,
167                None => {
168                    let file_stem = entry
169                        .path()
170                        .file_stem()
171                        .and_then(|s| s.to_str())
172                        .unwrap_or("");
173                    if file_stem.eq_ignore_ascii_case("SKILL")
174                        || file_stem.eq_ignore_ascii_case("index")
175                    {
176                        entry
177                            .path()
178                            .parent()
179                            .and_then(|p| p.file_name())
180                            .and_then(|n| n.to_str())
181                            .unwrap_or(file_stem)
182                            .to_string()
183                    } else {
184                        file_stem.to_string()
185                    }
186                }
187            };
188            let desc = parsed_desc.unwrap_or_default();
189            parsed_skills.push((name.clone(), desc));
190            skill_contents.insert(name, content);
191        } else {
192            // Check if it looks like it has frontmatter but is malformed
193            if content.starts_with("---\n") {
194                warn!("Malformed frontmatter in skill file {:?}", entry.path());
195            }
196        }
197    }
198
199    if parsed_skills.is_empty() {
200        bail!("No valid skills found in {}", source);
201    }
202
203    let selected_names = resolve_selection(&parsed_skills, mode, is_tty)?;
204    let slug = derive_source_slug(source);
205
206    // Sync mode (update / install --all): drop skills previously installed from
207    // this source that are no longer present upstream (deleted or renamed),
208    // so the graph mirrors the source. `--select` stays purely additive.
209    if prune {
210        let fresh: std::collections::HashSet<String> = selected_names
211            .iter()
212            .map(|n| crate::normalize::normalize_key(&format!("skill:{}:{}", slug, n)))
213            .collect();
214        let orphans: Vec<String> = crate::db::list_skills(conn)
215            .await?
216            .into_iter()
217            .filter(|s| derive_source_slug(&s.source) == slug && !fresh.contains(&s.entity_name))
218            .map(|s| s.entity_name)
219            .collect();
220        if !orphans.is_empty() {
221            warn!(
222                "Pruning {} orphaned skill(s) from {}",
223                orphans.len(),
224                source
225            );
226            crate::db::mcp_delete_entities(conn, orphans).await?;
227        }
228    }
229
230    for name in selected_names {
231        let body = skill_contents
232            .remove(&name)
233            .ok_or_else(|| anyhow!("Content missing for skill {}", name))?;
234        let description = parsed_skills
235            .iter()
236            .find(|(n, _)| n == &name)
237            .map(|(_, d)| d.as_str())
238            .unwrap_or("");
239
240        let entity_name = crate::normalize::normalize_key(&format!("skill:{}:{}", slug, name));
241
242        let tx = conn.transaction().await?;
243
244        // 1. Create the entity
245        tx.execute(
246            crate::constant::SQL_INSERT_ENTITY,
247            libsql::params![entity_name.clone(), "skill".to_string()],
248        )
249        .await?;
250
251        // 2. Set truths
252        tx.execute(
253            crate::constant::SQL_UPSERT_TRUTH,
254            libsql::params![entity_name.clone(), "description".to_string(), description],
255        )
256        .await?;
257        tx.execute(
258            crate::constant::SQL_UPSERT_TRUTH,
259            libsql::params![entity_name.clone(), "source".to_string(), source],
260        )
261        .await?;
262        tx.execute(
263            crate::constant::SQL_UPSERT_TRUTH,
264            libsql::params![entity_name.clone(), "version".to_string(), version],
265        )
266        .await?;
267        tx.execute(
268            crate::constant::SQL_UPSERT_TRUTH,
269            libsql::params![
270                entity_name.clone(),
271                "installed".to_string(),
272                chrono::Utc::now().format("%Y-%m-%d").to_string()
273            ],
274        )
275        .await?;
276
277        // 3. Upsert into mcp_skills
278        tx.execute(
279            crate::constant::SQL_UPSERT_SKILL,
280            libsql::params![entity_name.clone(), body.clone(), source, version],
281        )
282        .await?;
283
284        tx.commit().await?;
285
286        // 4. Chunk and embed into document store if available
287        #[cfg(feature = "documents")]
288        if let Some((store, embedder)) = vector_ctx {
289            // Delete old chunks for this topic before re-indexing
290            store.delete_by_topic(&entity_name).await?;
291            crate::db::delete_topic(conn, &entity_name).await?;
292
293            let texts = crate::chunk::chunk_text(&body, 512, 64);
294            if !texts.is_empty() {
295                let vectors = embedder.embed(&texts).await?;
296                let chunks: Vec<crate::vector::Chunk> = texts
297                    .into_iter()
298                    .zip(vectors)
299                    .enumerate()
300                    .map(|(i, (text, vector))| crate::vector::Chunk {
301                        id: uuid::Uuid::new_v4().to_string(),
302                        topic_id: entity_name.clone(),
303                        chunk_idx: i as u32,
304                        text,
305                        source: source.to_string(),
306                        vector,
307                    })
308                    .collect();
309                store.insert_chunks(chunks).await?;
310            }
311            crate::db::upsert_topic(conn, &entity_name, &name, source, &body).await?;
312        }
313    }
314
315    Ok(())
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_parse_frontmatter_valid() {
324        let content = "---\nname: my-skill\ndescription: \"does something\"\n---\nbody content";
325        let parsed = parse_frontmatter(content);
326        assert_eq!(
327            parsed,
328            Some((
329                Some("my-skill".to_string()),
330                Some("does something".to_string())
331            ))
332        );
333    }
334
335    #[test]
336    fn test_parse_frontmatter_missing() {
337        let content = "no frontmatter here";
338        let parsed = parse_frontmatter(content);
339        assert_eq!(parsed, None);
340    }
341
342    #[test]
343    fn test_parse_frontmatter_malformed() {
344        let content = "---\nname: partial-skill\n---\nbody content";
345        let parsed = parse_frontmatter(content);
346        assert_eq!(parsed, Some((Some("partial-skill".to_string()), None)));
347    }
348
349    #[test]
350    fn test_derive_source_slug() {
351        assert_eq!(
352            derive_source_slug("https://github.com/jasonswett/llm-skills.git"),
353            "jasonswett-llm-skills"
354        );
355        assert_eq!(
356            derive_source_slug("git@github.com:jasonswett/llm-skills.git"),
357            "jasonswett-llm-skills"
358        );
359        assert_eq!(
360            derive_source_slug("/path/to/local-skills"),
361            "to-local-skills"
362        );
363    }
364
365    #[test]
366    fn test_resolve_selection_all() {
367        let skills = vec![
368            ("skill-a".to_string(), "desc-a".to_string()),
369            ("skill-b".to_string(), "desc-b".to_string()),
370        ];
371        let selected = resolve_selection(&skills, SelectionMode::All, false).unwrap();
372        assert_eq!(selected, vec!["skill-a", "skill-b"]);
373    }
374
375    #[test]
376    fn test_resolve_selection_select() {
377        let skills = vec![
378            ("skill-a".to_string(), "desc-a".to_string()),
379            ("skill-b".to_string(), "desc-b".to_string()),
380        ];
381        let selected = resolve_selection(
382            &skills,
383            SelectionMode::Select(vec!["skill-b".to_string()]),
384            false,
385        )
386        .unwrap();
387        assert_eq!(selected, vec!["skill-b"]);
388    }
389
390    #[tokio::test]
391    async fn test_install_from_local_git_repo() {
392        use tempfile::tempdir;
393        let git_dir = tempdir().unwrap();
394        let repo_path = git_dir.path();
395
396        // 1. Initialize git repo
397        std::process::Command::new("git")
398            .arg("init")
399            .current_dir(repo_path)
400            .status()
401            .unwrap();
402
403        // Set git config for local commit
404        std::process::Command::new("git")
405            .arg("config")
406            .arg("user.name")
407            .arg("Test User")
408            .current_dir(repo_path)
409            .status()
410            .unwrap();
411        std::process::Command::new("git")
412            .arg("config")
413            .arg("user.email")
414            .arg("test@example.com")
415            .current_dir(repo_path)
416            .status()
417            .unwrap();
418
419        // 2. Create a skill file
420        let skill_file = repo_path.join("test-skill.md");
421        std::fs::write(
422            &skill_file,
423            "---\nname: repo-skill\ndescription: cloned skill\n---\nbody text\n",
424        )
425        .unwrap();
426
427        // 3. Commit the file
428        std::process::Command::new("git")
429            .arg("add")
430            .arg("test-skill.md")
431            .current_dir(repo_path)
432            .status()
433            .unwrap();
434        std::process::Command::new("git")
435            .arg("commit")
436            .arg("-m")
437            .arg("initial commit")
438            .current_dir(repo_path)
439            .status()
440            .unwrap();
441
442        // Get HEAD commit hash
443        let output = std::process::Command::new("git")
444            .arg("rev-parse")
445            .arg("HEAD")
446            .current_dir(repo_path)
447            .output()
448            .unwrap();
449        let head_commit = String::from_utf8(output.stdout).unwrap().trim().to_string();
450
451        // 4. Setup temp database
452        let db_dir = tempdir().unwrap();
453        unsafe {
454            std::env::set_var(
455                crate::db::ENV_DATABASE_URL,
456                db_dir.path().join("test.db").to_str().unwrap(),
457            );
458        }
459        let (_db, conn) = crate::db::init_db().await.unwrap();
460
461        // 5. Clone and install
462        let clone_temp_dir = tempdir().unwrap();
463        let clone_path = clone_temp_dir.path();
464        std::process::Command::new("git")
465            .arg("clone")
466            .arg(repo_path.to_str().unwrap())
467            .arg(clone_path.to_str().unwrap())
468            .status()
469            .unwrap();
470
471        install_skills_from_dir(
472            &conn,
473            clone_path,
474            repo_path.to_str().unwrap(),
475            &head_commit,
476            SelectionMode::All,
477            false,
478            true,
479            #[cfg(feature = "documents")]
480            None::<(
481                &crate::vector::VectorStore,
482                &crate::embed::fastembed_provider::FastEmbedProvider,
483            )>,
484        )
485        .await
486        .unwrap();
487
488        // 6. Verify skill installed
489        let skills = crate::db::list_skills(&conn).await.unwrap();
490        assert_eq!(skills.len(), 1);
491        assert_eq!(
492            skills[0].entity_name,
493            crate::normalize::normalize_key(&format!(
494                "skill:{}:repo-skill",
495                derive_source_slug(repo_path.to_str().unwrap())
496            ))
497        );
498        assert_eq!(skills[0].version, head_commit);
499    }
500
501    #[cfg(feature = "documents")]
502    #[tokio::test]
503    async fn test_install_skills_document_embedding() {
504        use tempfile::tempdir;
505        let git_dir = tempdir().unwrap();
506        let repo_path = git_dir.path();
507
508        // 1. Initialize git repo
509        std::process::Command::new("git")
510            .arg("init")
511            .current_dir(repo_path)
512            .status()
513            .unwrap();
514
515        // Set git config
516        std::process::Command::new("git")
517            .arg("config")
518            .arg("user.name")
519            .arg("Test User")
520            .current_dir(repo_path)
521            .status()
522            .unwrap();
523        std::process::Command::new("git")
524            .arg("config")
525            .arg("user.email")
526            .arg("test@example.com")
527            .current_dir(repo_path)
528            .status()
529            .unwrap();
530
531        // 2. Create a skill file with unique content
532        let skill_file = repo_path.join("test-skill.md");
533        std::fs::write(
534            &skill_file,
535            "---\nname: doc-skill\ndescription: searchable skill\n---\nHere is some unique knowledge about quantum cryptography.\n",
536        )
537        .unwrap();
538
539        // 3. Commit
540        std::process::Command::new("git")
541            .arg("add")
542            .arg("test-skill.md")
543            .current_dir(repo_path)
544            .status()
545            .unwrap();
546        std::process::Command::new("git")
547            .arg("commit")
548            .arg("-m")
549            .arg("initial commit")
550            .current_dir(repo_path)
551            .status()
552            .unwrap();
553
554        // Get HEAD commit hash
555        let output = std::process::Command::new("git")
556            .arg("rev-parse")
557            .arg("HEAD")
558            .current_dir(repo_path)
559            .output()
560            .unwrap();
561        let head_commit = String::from_utf8(output.stdout).unwrap().trim().to_string();
562
563        // 4. Setup temp database
564        let db_dir = tempdir().unwrap();
565        unsafe {
566            std::env::set_var(
567                crate::db::ENV_DATABASE_URL,
568                db_dir.path().join("test.db").to_str().unwrap(),
569            );
570        }
571        let (_db, conn) = crate::db::init_db().await.unwrap();
572
573        // Initialize VectorStore and FakeEmbedder
574        let store = crate::vector::VectorStore::new_with_dim(conn.clone(), 384);
575
576        struct FakeEmbedder(usize);
577        impl crate::embed::EmbeddingProvider for FakeEmbedder {
578            async fn embed(&self, texts: &[String]) -> anyhow::Result<Vec<Vec<f32>>> {
579                Ok(texts.iter().map(|_| vec![0.1f32; self.0]).collect())
580            }
581            fn dim(&self) -> usize {
582                self.0
583            }
584        }
585        let embedder = FakeEmbedder(384);
586
587        // 5. Clone and install passing vector context
588        let clone_temp_dir = tempdir().unwrap();
589        let clone_path = clone_temp_dir.path();
590        std::process::Command::new("git")
591            .arg("clone")
592            .arg(repo_path.to_str().unwrap())
593            .arg(clone_path.to_str().unwrap())
594            .status()
595            .unwrap();
596
597        install_skills_from_dir(
598            &conn,
599            clone_path,
600            repo_path.to_str().unwrap(),
601            &head_commit,
602            SelectionMode::All,
603            false,
604            true,
605            Some((&store, &embedder)),
606        )
607        .await
608        .unwrap();
609
610        // 6. Verify skill is queryable via recall
611        let results = crate::recall::recall("cryptography", &conn, &store, &embedder, 5)
612            .await
613            .unwrap();
614        assert!(!results.is_empty(), "expected skill to be queryable");
615
616        // Topic ID should be the normalized skill entity name
617        let slug = derive_source_slug(repo_path.to_str().unwrap());
618        let expected_topic_id =
619            crate::normalize::normalize_key(&format!("skill:{}:doc-skill", slug));
620        assert_eq!(results[0].topic_id, expected_topic_id);
621        assert_eq!(results[0].title, "doc-skill");
622        assert!(results[0].snippet.contains("quantum cryptography"));
623    }
624
625    #[tokio::test]
626    async fn test_sync_prunes_orphaned_skills() {
627        use tempfile::tempdir;
628        let src_dir = tempdir().unwrap();
629        let src = src_dir.path();
630
631        // Initial source with two skills.
632        std::fs::write(
633            src.join("alpha.md"),
634            "---\nname: alpha\ndescription: a\n---\nalpha body\n",
635        )
636        .unwrap();
637        std::fs::write(
638            src.join("beta.md"),
639            "---\nname: beta\ndescription: b\n---\nbeta body\n",
640        )
641        .unwrap();
642
643        let db_dir = tempdir().unwrap();
644        unsafe {
645            std::env::set_var(
646                crate::db::ENV_DATABASE_URL,
647                db_dir.path().join("test.db").to_str().unwrap(),
648            );
649        }
650        let (_db, conn) = crate::db::init_db().await.unwrap();
651
652        let source = src.to_str().unwrap();
653        let slug = derive_source_slug(source);
654
655        install_skills_from_dir(
656            &conn,
657            src,
658            source,
659            "v1",
660            SelectionMode::All,
661            false,
662            true,
663            #[cfg(feature = "documents")]
664            None::<(
665                &crate::vector::VectorStore,
666                &crate::embed::fastembed_provider::FastEmbedProvider,
667            )>,
668        )
669        .await
670        .unwrap();
671        assert_eq!(crate::db::list_skills(&conn).await.unwrap().len(), 2);
672
673        // Upstream removes `beta`; a sync (install --all) must prune it.
674        std::fs::remove_file(src.join("beta.md")).unwrap();
675
676        install_skills_from_dir(
677            &conn,
678            src,
679            source,
680            "v2",
681            SelectionMode::All,
682            false,
683            true,
684            #[cfg(feature = "documents")]
685            None::<(
686                &crate::vector::VectorStore,
687                &crate::embed::fastembed_provider::FastEmbedProvider,
688            )>,
689        )
690        .await
691        .unwrap();
692
693        let skills = crate::db::list_skills(&conn).await.unwrap();
694        assert_eq!(skills.len(), 1);
695        let alpha = crate::normalize::normalize_key(&format!("skill:{}:alpha", slug));
696        assert_eq!(skills[0].entity_name, alpha);
697        assert_eq!(skills[0].version, "v2");
698    }
699
700    #[tokio::test]
701    async fn test_select_does_not_prune() {
702        use tempfile::tempdir;
703        let src_dir = tempdir().unwrap();
704        let src = src_dir.path();
705
706        std::fs::write(
707            src.join("alpha.md"),
708            "---\nname: alpha\ndescription: a\n---\nalpha body\n",
709        )
710        .unwrap();
711        std::fs::write(
712            src.join("beta.md"),
713            "---\nname: beta\ndescription: b\n---\nbeta body\n",
714        )
715        .unwrap();
716
717        let db_dir = tempdir().unwrap();
718        unsafe {
719            std::env::set_var(
720                crate::db::ENV_DATABASE_URL,
721                db_dir.path().join("test.db").to_str().unwrap(),
722            );
723        }
724        let (_db, conn) = crate::db::init_db().await.unwrap();
725        let source = src.to_str().unwrap();
726
727        // Install only alpha, then only beta — both must survive (additive).
728        for name in ["alpha", "beta"] {
729            install_skills_from_dir(
730                &conn,
731                src,
732                source,
733                "v1",
734                SelectionMode::Select(vec![name.to_string()]),
735                false,
736                false,
737                #[cfg(feature = "documents")]
738                None::<(
739                    &crate::vector::VectorStore,
740                    &crate::embed::fastembed_provider::FastEmbedProvider,
741                )>,
742            )
743            .await
744            .unwrap();
745        }
746
747        assert_eq!(crate::db::list_skills(&conn).await.unwrap().len(), 2);
748    }
749
750    #[tokio::test]
751    async fn test_install_skills_with_fallbacks() {
752        use tempfile::tempdir;
753        let git_dir = tempdir().unwrap();
754        let repo_path = git_dir.path();
755
756        // 1. Initialize git repo
757        std::process::Command::new("git")
758            .arg("init")
759            .current_dir(repo_path)
760            .status()
761            .unwrap();
762
763        // Set git config for local commit
764        std::process::Command::new("git")
765            .arg("config")
766            .arg("user.name")
767            .arg("Test User")
768            .current_dir(repo_path)
769            .status()
770            .unwrap();
771        std::process::Command::new("git")
772            .arg("config")
773            .arg("user.email")
774            .arg("test@example.com")
775            .current_dir(repo_path)
776            .status()
777            .unwrap();
778
779        // 2. Create skill files with missing name and description respectively
780        let refactor_file = repo_path.join("refactor.md");
781        std::fs::write(
782            &refactor_file,
783            "---\ndescription: Iterative refactoring loop\n---\nrefactor body\n",
784        )
785        .unwrap();
786
787        let sdr_dir = repo_path.join("software-design-review");
788        std::fs::create_dir(&sdr_dir).unwrap();
789        let sdr_file = sdr_dir.join("SKILL.md");
790        std::fs::write(
791            &sdr_file,
792            "---\nname: software-design-review\n---\nsdr body\n",
793        )
794        .unwrap();
795
796        // 3. Commit files
797        std::process::Command::new("git")
798            .arg("add")
799            .arg("refactor.md")
800            .arg("software-design-review/SKILL.md")
801            .current_dir(repo_path)
802            .status()
803            .unwrap();
804        std::process::Command::new("git")
805            .arg("commit")
806            .arg("-m")
807            .arg("add skills")
808            .current_dir(repo_path)
809            .status()
810            .unwrap();
811
812        // Get HEAD commit hash
813        let output = std::process::Command::new("git")
814            .arg("rev-parse")
815            .arg("HEAD")
816            .current_dir(repo_path)
817            .output()
818            .unwrap();
819        let head_commit = String::from_utf8(output.stdout).unwrap().trim().to_string();
820
821        // 4. Setup temp database
822        let db_dir = tempdir().unwrap();
823        unsafe {
824            std::env::set_var(
825                crate::db::ENV_DATABASE_URL,
826                db_dir.path().join("test.db").to_str().unwrap(),
827            );
828        }
829        let (_db, conn) = crate::db::init_db().await.unwrap();
830
831        // 5. Clone and install
832        let clone_temp_dir = tempdir().unwrap();
833        let clone_path = clone_temp_dir.path();
834        std::process::Command::new("git")
835            .arg("clone")
836            .arg(repo_path.to_str().unwrap())
837            .arg(clone_path.to_str().unwrap())
838            .status()
839            .unwrap();
840
841        install_skills_from_dir(
842            &conn,
843            clone_path,
844            repo_path.to_str().unwrap(),
845            &head_commit,
846            SelectionMode::All,
847            false,
848            true,
849            #[cfg(feature = "documents")]
850            None::<(
851                &crate::vector::VectorStore,
852                &crate::embed::fastembed_provider::FastEmbedProvider,
853            )>,
854        )
855        .await
856        .unwrap();
857
858        // 6. Verify skills installed correctly with fallbacks
859        let skills = crate::db::list_skills(&conn).await.unwrap();
860        assert_eq!(skills.len(), 2);
861
862        let slug = derive_source_slug(repo_path.to_str().unwrap());
863
864        let refactor_entity = crate::normalize::normalize_key(&format!("skill:{}:refactor", slug));
865        let sdr_entity =
866            crate::normalize::normalize_key(&format!("skill:{}:software-design-review", slug));
867
868        let refactor_row = skills
869            .iter()
870            .find(|s| s.entity_name == refactor_entity)
871            .unwrap();
872        assert_eq!(refactor_row.description, "Iterative refactoring loop");
873
874        let sdr_row = skills.iter().find(|s| s.entity_name == sdr_entity).unwrap();
875        assert_eq!(sdr_row.description, "");
876    }
877}