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