1use anyhow::{Context, Result};
44use serde::{Deserialize, Serialize};
45use std::collections::HashMap;
46use std::fs;
47use std::path::{Path, PathBuf};
48
49pub const SKILLS_DIR: &str = "skills";
51
52pub const SKILL_MANIFEST: &str = "skill.toml";
54
55#[derive(Debug, Serialize, Deserialize, Clone)]
57pub struct SkillManifest {
58 pub skill: SkillMeta,
60 #[serde(default)]
62 pub hooks: Option<SkillHooks>,
63 #[serde(default)]
65 pub config: Option<HashMap<String, SkillConfigOption>>,
66}
67
68#[derive(Debug, Serialize, Deserialize, Clone)]
70pub struct SkillMeta {
71 pub name: String,
73 pub version: String,
75 pub description: String,
77 pub author: Option<String>,
79 #[serde(default)]
81 pub category: SkillCategory,
82 #[serde(default)]
84 pub tags: Vec<String>,
85}
86
87#[derive(Debug, Serialize, Deserialize, Clone, Default)]
89#[serde(rename_all = "kebab-case")]
90pub enum SkillCategory {
91 #[default]
93 Template,
94 Analyzer,
96 Formatter,
98 Integration,
100 Utility,
102}
103
104impl std::fmt::Display for SkillCategory {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 match self {
107 SkillCategory::Template => write!(f, "template"),
108 SkillCategory::Analyzer => write!(f, "analyzer"),
109 SkillCategory::Formatter => write!(f, "formatter"),
110 SkillCategory::Integration => write!(f, "integration"),
111 SkillCategory::Utility => write!(f, "utility"),
112 }
113 }
114}
115
116#[derive(Debug, Serialize, Deserialize, Clone, Default)]
118pub struct SkillHooks {
119 pub pre_gen: Option<String>,
121 pub post_gen: Option<String>,
123 pub format: Option<String>,
125}
126
127#[derive(Debug, Serialize, Deserialize, Clone)]
129pub struct SkillConfigOption {
130 pub r#type: String,
132 #[serde(default)]
134 pub default: Option<toml::Value>,
135 pub description: String,
137 #[serde(default)]
139 pub required: bool,
140}
141
142#[derive(Debug, Clone)]
144pub struct Skill {
145 pub manifest: SkillManifest,
147 pub path: PathBuf,
149 pub source: SkillSource,
151}
152
153#[allow(dead_code)]
154impl Skill {
155 pub fn name(&self) -> &str {
157 &self.manifest.skill.name
158 }
159
160 pub fn description(&self) -> &str {
162 &self.manifest.skill.description
163 }
164
165 pub fn category(&self) -> &SkillCategory {
167 &self.manifest.skill.category
168 }
169
170 pub fn source(&self) -> &SkillSource {
172 &self.source
173 }
174
175 pub fn is_builtin(&self) -> bool {
177 matches!(self.source, SkillSource::Builtin)
178 }
179
180 pub fn is_project_skill(&self) -> bool {
182 matches!(self.source, SkillSource::Project)
183 }
184
185 pub fn has_pre_gen(&self) -> bool {
187 self.manifest
188 .hooks
189 .as_ref()
190 .and_then(|h| h.pre_gen.as_ref())
191 .is_some()
192 }
193
194 pub fn has_post_gen(&self) -> bool {
196 self.manifest
197 .hooks
198 .as_ref()
199 .and_then(|h| h.post_gen.as_ref())
200 .is_some()
201 }
202
203 pub fn pre_gen_path(&self) -> Option<PathBuf> {
205 self.manifest
206 .hooks
207 .as_ref()
208 .and_then(|h| h.pre_gen.as_ref().map(|script| self.path.join(script)))
209 }
210
211 pub fn post_gen_path(&self) -> Option<PathBuf> {
213 self.manifest
214 .hooks
215 .as_ref()
216 .and_then(|h| h.post_gen.as_ref().map(|script| self.path.join(script)))
217 }
218
219 pub fn load_prompt_template(&self) -> Result<Option<String>> {
221 let prompt_path = self.path.join("prompt.md");
222 if prompt_path.exists() {
223 let content = fs::read_to_string(&prompt_path).with_context(|| {
224 format!("Failed to read prompt template: {}", prompt_path.display())
225 })?;
226 Ok(Some(content))
227 } else {
228 Ok(None)
229 }
230 }
231}
232
233#[derive(Debug, Clone, PartialEq)]
235#[allow(dead_code)]
236pub enum SkillSource {
237 Builtin,
239 User,
241 Project,
243}
244
245impl std::fmt::Display for SkillSource {
246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 match self {
248 SkillSource::Builtin => write!(f, "built-in"),
249 SkillSource::User => write!(f, "user"),
250 SkillSource::Project => write!(f, "project"),
251 }
252 }
253}
254
255pub struct SkillsManager {
257 user_skills_dir: PathBuf,
259 project_skills_dir: Option<PathBuf>,
261 skills: Vec<Skill>,
263}
264
265#[allow(dead_code)]
266impl SkillsManager {
267 pub fn new() -> Result<Self> {
269 let user_skills_dir = Self::user_skills_dir()?;
270 let project_skills_dir = Self::project_skills_dir()?;
271 Ok(Self {
272 user_skills_dir,
273 project_skills_dir,
274 skills: Vec::new(),
275 })
276 }
277
278 fn user_skills_dir() -> Result<PathBuf> {
280 let config_dir = if let Ok(config_home) = std::env::var("RCO_CONFIG_HOME") {
281 PathBuf::from(config_home)
282 } else {
283 dirs::home_dir()
284 .context("Could not find home directory")?
285 .join(".config")
286 .join("rustycommit")
287 };
288 Ok(config_dir.join(SKILLS_DIR))
289 }
290
291 fn project_skills_dir() -> Result<Option<PathBuf>> {
293 use crate::git;
294
295 if let Ok(repo_root) = git::get_repo_root() {
297 let project_skills = Path::new(&repo_root).join(".rco").join("skills");
298 if project_skills.exists() {
299 return Ok(Some(project_skills));
300 }
301 }
302 Ok(None)
303 }
304
305 pub fn has_project_skills(&self) -> bool {
307 self.project_skills_dir.is_some()
308 }
309
310 pub fn project_skills_path(&self) -> Option<&Path> {
312 self.project_skills_dir.as_deref()
313 }
314
315 pub fn ensure_skills_dir(&self) -> Result<()> {
317 if !self.user_skills_dir.exists() {
318 fs::create_dir_all(&self.user_skills_dir).with_context(|| {
319 format!(
320 "Failed to create skills directory: {}",
321 self.user_skills_dir.display()
322 )
323 })?;
324 }
325 Ok(())
326 }
327
328 pub fn discover(&mut self) -> Result<&mut Self> {
331 self.skills.clear();
332 let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
333
334 if let Some(ref project_dir) = self.project_skills_dir {
336 if project_dir.exists() {
337 for entry in fs::read_dir(project_dir)? {
338 let entry = entry?;
339 let path = entry.path();
340 if path.is_dir() {
341 if let Ok(skill) = Self::load_skill(&path, SkillSource::Project) {
342 seen_names.insert(skill.name().to_string());
343 self.skills.push(skill);
344 }
345 }
346 }
347 }
348 }
349
350 if self.user_skills_dir.exists() {
352 for entry in fs::read_dir(&self.user_skills_dir)? {
353 let entry = entry?;
354 let path = entry.path();
355 if path.is_dir() {
356 if let Ok(skill) = Self::load_skill(&path, SkillSource::User) {
357 if !seen_names.contains(skill.name()) {
358 self.skills.push(skill);
359 }
360 }
361 }
362 }
363 }
364
365 self.skills.sort_by(|a, b| a.name().cmp(b.name()));
367
368 Ok(self)
369 }
370
371 fn load_skill(path: &Path, source: SkillSource) -> Result<Skill> {
373 let manifest_path = path.join(SKILL_MANIFEST);
374 let manifest_content = fs::read_to_string(&manifest_path).with_context(|| {
375 format!("Failed to read skill manifest: {}", manifest_path.display())
376 })?;
377 let manifest: SkillManifest = toml::from_str(&manifest_content).with_context(|| {
378 format!(
379 "Failed to parse skill manifest: {}",
380 manifest_path.display()
381 )
382 })?;
383
384 Ok(Skill {
385 manifest,
386 path: path.to_path_buf(),
387 source,
388 })
389 }
390
391 pub fn skills(&self) -> &[Skill] {
393 &self.skills
394 }
395
396 pub fn find(&self, name: &str) -> Option<&Skill> {
398 self.skills.iter().find(|s| s.name() == name)
399 }
400
401 pub fn find_mut(&mut self, name: &str) -> Option<&mut Skill> {
403 self.skills.iter_mut().find(|s| s.name() == name)
404 }
405
406 pub fn by_category(&self, category: &SkillCategory) -> Vec<&Skill> {
408 self.skills
409 .iter()
410 .filter(|s| std::mem::discriminant(s.category()) == std::mem::discriminant(category))
411 .collect()
412 }
413
414 pub fn create_skill(&self, name: &str, category: SkillCategory) -> Result<PathBuf> {
416 self.ensure_skills_dir()?;
417
418 let skill_dir = self.user_skills_dir.join(name);
419 if skill_dir.exists() {
420 anyhow::bail!("Skill '{}' already exists at {}", name, skill_dir.display());
421 }
422
423 fs::create_dir_all(&skill_dir)?;
424
425 let manifest = SkillManifest {
427 skill: SkillMeta {
428 name: name.to_string(),
429 version: "1.0.0".to_string(),
430 description: format!("A {} skill for rusty-commit", category),
431 author: None,
432 category,
433 tags: vec![],
434 },
435 hooks: None,
436 config: None,
437 };
438
439 let manifest_content = toml::to_string_pretty(&manifest)?;
440 fs::write(skill_dir.join(SKILL_MANIFEST), manifest_content)?;
441
442 let prompt_template = r#"# Custom Prompt Template
444
445You are a commit message generator. Analyze the following diff and generate a commit message.
446
447## Diff
448
449```diff
450{diff}
451```
452
453## Context
454
455{context}
456
457## Instructions
458
459Generate a commit message that:
460- Follows the conventional commit format
461- Is clear and concise
462- Describes the changes accurately
463"#;
464
465 fs::write(skill_dir.join("prompt.md"), prompt_template)?;
466
467 Ok(skill_dir)
468 }
469
470 pub fn remove_skill(&mut self, name: &str) -> Result<()> {
472 if let Some(skill) = self.find(name) {
474 if !matches!(skill.source, SkillSource::User) {
475 anyhow::bail!(
476 "Cannot remove {} skill '{}'. Only user skills can be removed.",
477 skill.source,
478 name
479 );
480 }
481 }
482
483 let skill_dir = self.user_skills_dir.join(name);
484 if !skill_dir.exists() {
485 anyhow::bail!("Skill '{}' not found", name);
486 }
487
488 fs::remove_dir_all(&skill_dir).with_context(|| {
489 format!("Failed to remove skill directory: {}", skill_dir.display())
490 })?;
491
492 self.skills.retain(|s| s.name() != name);
494
495 Ok(())
496 }
497
498 pub fn skills_dir(&self) -> &Path {
500 &self.user_skills_dir
501 }
502
503 pub fn ensure_project_skills_dir(&self) -> Result<Option<PathBuf>> {
505 use crate::git;
506
507 if let Ok(repo_root) = git::get_repo_root() {
508 let project_skills = Path::new(&repo_root).join(".rco").join("skills");
509 if !project_skills.exists() {
510 fs::create_dir_all(&project_skills).with_context(|| {
511 format!(
512 "Failed to create project skills directory: {}",
513 project_skills.display()
514 )
515 })?;
516 }
517 return Ok(Some(project_skills));
518 }
519 Ok(None)
520 }
521}
522
523impl Default for SkillsManager {
524 fn default() -> Self {
525 Self::new().expect("Failed to create skills manager")
526 }
527}
528
529#[allow(dead_code)]
531pub mod builtin {
532
533 pub fn conventional_prompt(diff: &str, context: Option<&str>, language: &str) -> String {
535 let context_str = context.unwrap_or("None");
536 format!(
537 r#"You are an expert at writing conventional commit messages.
538
539Analyze the following git diff and generate a conventional commit message.
540
541## Rules
542- Use format: <type>(<scope>): <description>
543- Types:
544 - feat: A new feature
545 - fix: A bug fix
546 - docs: Documentation only changes
547 - style: Changes that don't affect code meaning (formatting, semicolons, etc.)
548 - refactor: Code change that neither fixes a bug nor adds a feature
549 - perf: Code change that improves performance
550 - test: Adding or correcting tests
551 - build: Changes to build system or dependencies
552 - ci: Changes to CI configuration
553 - chore: Other changes that don't modify src or test files
554- Keep the description under 72 characters
555- Use imperative mood ("add" not "added")
556- Be concise but descriptive
557- Scope is optional but recommended for monorepos or large projects
558- For breaking changes, add ! after type/scope: feat(api)!: change API response format
559
560## Context
561{}
562
563## Language
564{}
565
566## Diff
567
568```diff
569{}
570```
571
572Generate ONLY the commit message, no explanation:"#,
573 context_str, language, diff
574 )
575 }
576
577 pub fn gitmoji_prompt(diff: &str, context: Option<&str>, language: &str) -> String {
579 let context_str = context.unwrap_or("None");
580 format!(
581 r#"You are an expert at writing GitMoji commit messages.
582
583Analyze the following git diff and generate a GitMoji commit message.
584
585## Rules
586- Start with an appropriate emoji
587- Use format: :emoji: <description> OR emoji <description>
588- Common emojis (from gitmoji.dev):
589 - โจ :sparkles: (feat) - Introduce new features
590 - ๐ :bug: (fix) - Fix a bug
591 - ๐ :memo: (docs) - Add or update documentation
592 - ๐ :lipstick: (style) - Add or update the UI/style files
593 - โป๏ธ :recycle: (refactor) - Refactor code
594 - โ
:white_check_mark: (test) - Add or update tests
595 - ๐ง :wrench: (chore) - Add or update configuration files
596 - โก๏ธ :zap: (perf) - Improve performance
597 - ๐ท :construction_worker: (ci) - Add or update CI build system
598 - ๐ฆ :package: (build) - Add or update compiled files/packages
599 - ๐จ :art: - Improve structure/format of the code
600 - ๐ฅ :fire: - Remove code or files
601 - ๐ :rocket: - Deploy stuff
602 - ๐ :lock: - Fix security issues
603 - โฌ๏ธ :arrow_up: - Upgrade dependencies
604 - โฌ๏ธ :arrow_down: - Downgrade dependencies
605 - ๐ :pushpin: - Pin dependencies to specific versions
606 - โ :heavy_plus_sign: - Add dependencies
607 - โ :heavy_minus_sign: - Remove dependencies
608 - ๐ :twisted_rightwards_arrows: - Merge branches
609 - ๐ฅ :boom: - Introduce breaking changes
610 - ๐ :ambulance: - Critical hotfix
611 - ๐ฑ :bento: - Add or update assets
612 - ๐๏ธ :wastebasket: - Deprecate code
613 - โฐ๏ธ :coffin: - Remove dead code
614 - ๐งช :test_tube: - Add failing test
615 - ๐ฉน :adhesive_bandage: - Simple fix for a non-critical issue
616 - ๐ :globe_with_meridians: - Internationalization and localization
617 - ๐ก :bulb: - Add or update comments in source code
618 - ๐๏ธ :card_file_box: - Database related changes
619- Keep the description under 72 characters
620- Use imperative mood
621- For breaking changes, add ๐ฅ after the emoji
622
623## Context
624{}
625
626## Language
627{}
628
629## Diff
630
631```diff
632{}
633```
634
635Generate ONLY the commit message, no explanation:"#,
636 context_str, language, diff
637 )
638 }
639}
640
641pub mod external {
643 use super::*;
644 use std::process::Command;
645
646 #[derive(Debug, Clone)]
648 pub enum ExternalSource {
649 ClaudeCode,
651 GitHub {
653 owner: String,
654 repo: String,
655 path: Option<String>,
656 },
657 Gist { id: String },
659 Url { url: String },
661 }
662
663 impl std::fmt::Display for ExternalSource {
664 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
665 match self {
666 ExternalSource::ClaudeCode => write!(f, "claude-code"),
667 ExternalSource::GitHub { owner, repo, .. } => {
668 write!(f, "github:{}/{}", owner, repo)
669 }
670 ExternalSource::Gist { id } => write!(f, "gist:{}", id),
671 ExternalSource::Url { url } => write!(f, "url:{}", url),
672 }
673 }
674 }
675
676 pub fn parse_source(source: &str) -> Result<ExternalSource> {
685 if source == "claude-code" || source == "claude" {
686 Ok(ExternalSource::ClaudeCode)
687 } else if let Some(github_ref) = source.strip_prefix("github:") {
688 let parts: Vec<&str> = github_ref.split('/').collect();
690 if parts.len() < 2 {
691 anyhow::bail!("Invalid GitHub reference. Use format: github:owner/repo or github:owner/repo/path");
692 }
693 let owner = parts[0].to_string();
694 let repo = parts[1].to_string();
695 let path = if parts.len() > 2 {
696 Some(parts[2..].join("/"))
697 } else {
698 None
699 };
700 Ok(ExternalSource::GitHub { owner, repo, path })
701 } else if let Some(gist_id) = source.strip_prefix("gist:") {
702 Ok(ExternalSource::Gist {
703 id: gist_id.to_string(),
704 })
705 } else if source.starts_with("http://") || source.starts_with("https://") {
706 Ok(ExternalSource::Url {
707 url: source.to_string(),
708 })
709 } else {
710 anyhow::bail!("Unknown source format: {}. Use 'claude-code', 'github:owner/repo', 'gist:id', or a URL", source)
711 }
712 }
713
714 pub fn import_from_claude_code(target_dir: &Path) -> Result<Vec<String>> {
718 let claude_skills_dir = dirs::home_dir()
719 .context("Could not find home directory")?
720 .join(".claude")
721 .join("skills");
722
723 if !claude_skills_dir.exists() {
724 anyhow::bail!("Claude Code skills directory not found at ~/.claude/skills/");
725 }
726
727 let mut imported = Vec::new();
728
729 for entry in fs::read_dir(&claude_skills_dir)? {
730 let entry = entry?;
731 let path = entry.path();
732
733 if path.is_dir() {
734 let skill_name = path
735 .file_name()
736 .and_then(|n| n.to_str())
737 .unwrap_or("unknown")
738 .to_string();
739
740 let target_skill_dir = target_dir.join(&skill_name);
742
743 if target_skill_dir.exists() {
744 tracing::warn!("Skill '{}' already exists, skipping", skill_name);
745 continue;
746 }
747
748 fs::create_dir_all(&target_skill_dir)?;
749
750 convert_claude_skill(&path, &target_skill_dir, &skill_name)?;
752
753 imported.push(skill_name);
754 }
755 }
756
757 Ok(imported)
758 }
759
760 pub fn convert_claude_skill(source: &Path, target: &Path, name: &str) -> Result<()> {
762 if !source.exists() {
768 anyhow::bail!("Source directory does not exist: {:?}", source);
769 }
770
771 fs::create_dir_all(target)
773 .with_context(|| format!("Failed to create target directory: {:?}", target))?;
774
775 let description = if source.join("README.md").exists() {
777 let readme = fs::read_to_string(source.join("README.md"))
779 .with_context(|| format!("Failed to read README.md from {:?}", source))?;
780 readme
781 .lines()
782 .next()
783 .unwrap_or("Imported from Claude Code")
784 .to_string()
785 } else if source.join("SKILL.md").exists() {
786 let skill_md = fs::read_to_string(source.join("SKILL.md"))
788 .with_context(|| format!("Failed to read SKILL.md from {:?}", source))?;
789 skill_md
790 .lines()
791 .next()
792 .unwrap_or("Imported from Claude Code")
793 .to_string()
794 } else {
795 format!("Imported from Claude Code: {}", name)
796 };
797
798 let manifest = SkillManifest {
799 skill: SkillMeta {
800 name: name.to_string(),
801 version: "1.0.0".to_string(),
802 description,
803 author: Some("Imported from Claude Code".to_string()),
804 category: SkillCategory::Template,
805 tags: vec!["claude-code".to_string(), "imported".to_string()],
806 },
807 hooks: None,
808 config: None,
809 };
810
811 fs::write(
812 target.join("skill.toml"),
813 toml::to_string_pretty(&manifest)?,
814 )?;
815
816 let instruction_files = [
819 "SKILL.md",
820 "INSTRUCTIONS.md",
821 "README.md",
822 "PROMPT.md",
823 "prompt.md",
824 ];
825 let mut found_instructions = false;
826
827 for file in &instruction_files {
828 let source_file = source.join(file);
829 if source_file.exists() {
830 let content = fs::read_to_string(&source_file)?;
831 let prompt = format!(
833 "# Imported from Claude Code Skill: {}\n\n{}\n\n## Diff\n\n```diff\n{{diff}}\n```\n\n## Context\n\n{{context}}",
834 name,
835 content
836 );
837 fs::write(target.join("prompt.md"), prompt)?;
838 found_instructions = true;
839 break;
840 }
841 }
842
843 if !found_instructions {
844 let prompt = format!(
846 "# Skill: {}\n\nThis skill was imported from Claude Code.\n\n## Diff\n\n```diff\n{{diff}}\n```\n\n## Context\n\n{{context}}",
847 name
848 );
849 fs::write(target.join("prompt.md"), prompt)?;
850 }
851
852 for entry in fs::read_dir(source)? {
854 let entry = entry?;
855 let file_name = entry.file_name();
856 let file_str = file_name.to_string_lossy();
857
858 if file_str.ends_with(".json") && file_str.contains("tool") {
860 continue; }
862 if ["skill.toml", "prompt.md", "README.md", "INSTRUCTIONS.md"]
863 .contains(&file_str.as_ref())
864 {
865 continue;
866 }
867
868 let target_file = target.join(&file_name);
870 if entry.path().is_file() {
871 fs::copy(entry.path(), target_file)?;
872 }
873 }
874
875 Ok(())
876 }
877
878 pub fn import_from_github(
882 owner: &str,
883 repo: &str,
884 path: Option<&str>,
885 target_dir: &Path,
886 ) -> Result<Vec<String>> {
887 use std::env;
888
889 let temp_dir = env::temp_dir().join(format!("rco-github-import-{}-{}", owner, repo));
891
892 if temp_dir.exists() {
894 let _ = fs::remove_dir_all(&temp_dir);
895 }
896
897 println!("Cloning {}/{}...", owner, repo);
899 let status = Command::new("git")
900 .args([
901 "clone",
902 "--depth",
903 "1",
904 &format!("https://github.com/{}/{}", owner, repo),
905 temp_dir.to_string_lossy().as_ref(),
906 ])
907 .status()
908 .context("Failed to run git clone. Is git installed?")?;
909
910 if !status.success() {
911 anyhow::bail!("Failed to clone repository {}/{}", owner, repo);
912 }
913
914 let source_path = if let Some(p) = path {
916 temp_dir.join(p)
917 } else {
918 temp_dir.join(".rco").join("skills")
919 };
920
921 if !source_path.exists() {
922 let _ = fs::remove_dir_all(&temp_dir);
923 anyhow::bail!(
924 "No skills found at {} in {}/{}",
925 source_path.display(),
926 owner,
927 repo
928 );
929 }
930
931 let mut imported = Vec::new();
933
934 for entry in fs::read_dir(&source_path)? {
935 let entry = entry?;
936 let path = entry.path();
937
938 if path.is_dir() {
939 let skill_name = path
940 .file_name()
941 .and_then(|n| n.to_str())
942 .unwrap_or("unknown")
943 .to_string();
944
945 let target_skill_dir = target_dir.join(&skill_name);
946
947 if target_skill_dir.exists() {
948 tracing::warn!("Skill '{}' already exists, skipping", skill_name);
949 continue;
950 }
951
952 copy_dir_all(&path, &target_skill_dir)?;
954
955 let skill_toml = target_skill_dir.join("skill.toml");
957 if skill_toml.exists() {
958 if let Ok(content) = fs::read_to_string(&skill_toml) {
959 if let Ok(mut manifest) = toml::from_str::<SkillManifest>(&content) {
960 manifest.skill.tags.push("github".to_string());
961 manifest.skill.tags.push("imported".to_string());
962 let _ = fs::write(&skill_toml, toml::to_string_pretty(&manifest)?);
963 }
964 }
965 }
966
967 imported.push(skill_name);
968 }
969 }
970
971 let _ = fs::remove_dir_all(&temp_dir);
973
974 Ok(imported)
975 }
976
977 pub fn import_from_gist(gist_id: &str, target_dir: &Path) -> Result<String> {
979 let gist_url = format!("https://api.github.com/gists/{}", gist_id);
981
982 let client = reqwest::blocking::Client::new();
983 let response = client
984 .get(&gist_url)
985 .header("User-Agent", "rusty-commit")
986 .send()
987 .context("Failed to fetch gist from GitHub API")?;
988
989 if !response.status().is_success() {
990 anyhow::bail!("Failed to fetch gist: HTTP {}", response.status());
991 }
992
993 let gist_data: serde_json::Value =
994 response.json().context("Failed to parse gist response")?;
995
996 let files = gist_data["files"]
997 .as_object()
998 .ok_or_else(|| anyhow::anyhow!("Invalid gist data: no files"))?;
999
1000 if files.is_empty() {
1001 anyhow::bail!("Gist contains no files");
1002 }
1003
1004 let (filename, file_data) = files.iter().next().unwrap();
1006 let skill_name = filename.trim_end_matches(".md").trim_end_matches(".toml");
1007
1008 let target_skill_dir = target_dir.join(skill_name);
1009 if target_skill_dir.exists() {
1010 anyhow::bail!("Skill '{}' already exists", skill_name);
1011 }
1012
1013 fs::create_dir_all(&target_skill_dir)?;
1014
1015 if let Some(content) = file_data["content"].as_str() {
1017 if filename.ends_with(".toml") {
1019 fs::write(target_skill_dir.join("skill.toml"), content)?;
1020 } else {
1021 let prompt = format!(
1023 "# Imported from Gist: {}\n\n{}\n\n## Diff\n\n```diff\n{{diff}}\n```\n\n## Context\n\n{{context}}",
1024 gist_id,
1025 content
1026 );
1027 fs::write(target_skill_dir.join("prompt.md"), prompt)?;
1028
1029 let manifest = SkillManifest {
1031 skill: SkillMeta {
1032 name: skill_name.to_string(),
1033 version: "1.0.0".to_string(),
1034 description: format!("Imported from Gist: {}", gist_id),
1035 author: gist_data["owner"]["login"].as_str().map(|s| s.to_string()),
1036 category: SkillCategory::Template,
1037 tags: vec!["gist".to_string(), "imported".to_string()],
1038 },
1039 hooks: None,
1040 config: None,
1041 };
1042 fs::write(
1043 target_skill_dir.join("skill.toml"),
1044 toml::to_string_pretty(&manifest)?,
1045 )?;
1046 }
1047 }
1048
1049 Ok(skill_name.to_string())
1050 }
1051
1052 pub fn import_from_url(url: &str, name: Option<&str>, target_dir: &Path) -> Result<String> {
1054 let client = reqwest::blocking::Client::new();
1055 let response = client
1056 .get(url)
1057 .header("User-Agent", "rusty-commit")
1058 .send()
1059 .context("Failed to download from URL")?;
1060
1061 if !response.status().is_success() {
1062 anyhow::bail!("Failed to download: HTTP {}", response.status());
1063 }
1064
1065 let content = response.text()?;
1066
1067 let skill_name = name.map(|s| s.to_string()).unwrap_or_else(|| {
1069 url.split('/')
1070 .next_back()
1071 .and_then(|s| s.split('.').next())
1072 .unwrap_or("imported-skill")
1073 .to_string()
1074 });
1075
1076 let target_skill_dir = target_dir.join(&skill_name);
1077 if target_skill_dir.exists() {
1078 anyhow::bail!("Skill '{}' already exists", skill_name);
1079 }
1080
1081 fs::create_dir_all(&target_skill_dir)?;
1082
1083 if content.trim().starts_with('[') && content.contains("[skill]") {
1085 fs::write(target_skill_dir.join("skill.toml"), content)?;
1086 } else {
1087 let prompt = format!(
1089 "# Imported from URL\n\n{}\n\n## Diff\n\n```diff\n{{diff}}\n```\n\n## Context\n\n{{context}}",
1090 content
1091 );
1092 fs::write(target_skill_dir.join("prompt.md"), prompt)?;
1093
1094 let manifest = SkillManifest {
1096 skill: SkillMeta {
1097 name: skill_name.clone(),
1098 version: "1.0.0".to_string(),
1099 description: format!("Imported from {}", url),
1100 author: None,
1101 category: SkillCategory::Template,
1102 tags: vec!["url".to_string(), "imported".to_string()],
1103 },
1104 hooks: None,
1105 config: None,
1106 };
1107 fs::write(
1108 target_skill_dir.join("skill.toml"),
1109 toml::to_string_pretty(&manifest)?,
1110 )?;
1111 }
1112
1113 Ok(skill_name)
1114 }
1115
1116 fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
1118 fs::create_dir_all(dst)?;
1119
1120 for entry in fs::read_dir(src)? {
1121 let entry = entry?;
1122 let path = entry.path();
1123 let file_name = path.file_name().unwrap();
1124 let dst_path = dst.join(file_name);
1125
1126 if path.is_dir() {
1127 copy_dir_all(&path, &dst_path)?;
1128 } else {
1129 fs::copy(&path, &dst_path)?;
1130 }
1131 }
1132
1133 Ok(())
1134 }
1135
1136 pub fn list_claude_code_skills() -> Result<Vec<(String, String)>> {
1138 let claude_skills_dir = dirs::home_dir()
1139 .context("Could not find home directory")?
1140 .join(".claude")
1141 .join("skills");
1142
1143 if !claude_skills_dir.exists() {
1144 return Ok(Vec::new());
1145 }
1146
1147 let mut skills = Vec::new();
1148
1149 for entry in fs::read_dir(&claude_skills_dir)? {
1150 let entry = entry?;
1151 let path = entry.path();
1152
1153 if path.is_dir() {
1154 let name = path
1155 .file_name()
1156 .and_then(|n| n.to_str())
1157 .unwrap_or("unknown")
1158 .to_string();
1159
1160 let description = if path.join("README.md").exists() {
1162 let readme = fs::read_to_string(path.join("README.md")).unwrap_or_default();
1163 readme
1164 .lines()
1165 .next()
1166 .unwrap_or("No description")
1167 .to_string()
1168 } else {
1169 "Claude Code skill".to_string()
1170 };
1171
1172 skills.push((name, description));
1173 }
1174 }
1175
1176 Ok(skills)
1177 }
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182 use super::*;
1183
1184 #[test]
1185 fn test_skill_category_display() {
1186 assert_eq!(SkillCategory::Template.to_string(), "template");
1187 assert_eq!(SkillCategory::Analyzer.to_string(), "analyzer");
1188 assert_eq!(SkillCategory::Formatter.to_string(), "formatter");
1189 }
1190
1191 #[test]
1192 fn test_manifest_parsing() {
1193 let toml = r#"
1194[skill]
1195name = "test-skill"
1196version = "1.0.0"
1197description = "A test skill"
1198author = "Test Author"
1199category = "template"
1200tags = ["test", "example"]
1201
1202[skill.hooks]
1203pre_gen = "pre_gen.sh"
1204post_gen = "post_gen.sh"
1205"#;
1206
1207 let manifest: SkillManifest = toml::from_str(toml).unwrap();
1208 assert_eq!(manifest.skill.name, "test-skill");
1209 assert_eq!(manifest.skill.version, "1.0.0");
1210 assert!(matches!(manifest.skill.category, SkillCategory::Template));
1211 assert_eq!(manifest.skill.tags.len(), 2);
1212 }
1213}