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