Skip to main content

rusty_commit/skills/
mod.rs

1//! Skills System for Rusty Commit
2//!
3//! Skills are modular extensions that allow users to customize and extend
4//! rusty-commit's functionality. They can be used to:
5//!
6//! - Define custom commit message templates
7//! - Add custom analysis modules
8//! - Create custom output formatters
9//! - Hook into the commit generation pipeline
10//!
11//! # Skill Structure
12//!
13//! Skills are stored in `~/.config/rustycommit/skills/` and are either:
14//! - **Built-in**: Included with rusty-commit
15//! - **Local**: User-created skills in the skills directory
16//! - **Project**: Team-shared skills in `.rco/skills/`
17//! - **External**: Imported from Claude Code, GitHub, etc.
18//!
19//! # Skill Manifest
20//!
21//! Each skill has a `skill.toml` manifest:
22//! ```toml
23//! [skill]
24//! name = "conventional-with-scope"
25//! version = "1.0.0"
26//! description = "Conventional commits with automatic scope detection"
27//! author = "Your Name"
28//!
29//! [skill.hooks]
30//! pre_gen = "pre_gen.sh"      # Optional: runs before AI generation
31//! post_gen = "post_gen.sh"    # Optional: runs after AI generation
32//! format = "format.sh"        # Optional: formats the output
33//! ```
34//!
35//! # External Skills
36//!
37//! rusty-commit can import skills from:
38//! - **Claude Code**: `~/.claude/skills/` - Claude Code custom skills
39//! - **GitHub**: Repositories with `.rco/skills/` directory
40//! - **GitHub Gist**: Single-file skill definitions
41//! - **URL**: Direct download from any HTTP(S) URL
42
43use anyhow::{Context, Result};
44use serde::{Deserialize, Serialize};
45use std::collections::HashMap;
46use std::fs;
47use std::path::{Path, PathBuf};
48
49/// Skills directory name
50pub const SKILLS_DIR: &str = "skills";
51
52/// Skill manifest filename
53pub const SKILL_MANIFEST: &str = "skill.toml";
54
55/// A skill manifest defining the skill's metadata and capabilities
56#[derive(Debug, Serialize, Deserialize, Clone)]
57pub struct SkillManifest {
58    /// Skill metadata
59    pub skill: SkillMeta,
60    /// Optional hooks for the skill
61    #[serde(default)]
62    pub hooks: Option<SkillHooks>,
63    /// Configuration schema (optional)
64    #[serde(default)]
65    pub config: Option<HashMap<String, SkillConfigOption>>,
66}
67
68/// Skill metadata
69#[derive(Debug, Serialize, Deserialize, Clone)]
70pub struct SkillMeta {
71    /// Unique name for the skill
72    pub name: String,
73    /// Semantic version
74    pub version: String,
75    /// Human-readable description
76    pub description: String,
77    /// Author name or email
78    pub author: Option<String>,
79    /// Skill category
80    #[serde(default)]
81    pub category: SkillCategory,
82    /// Tags for discovery
83    #[serde(default)]
84    pub tags: Vec<String>,
85}
86
87/// Skill categories
88#[derive(Debug, Serialize, Deserialize, Clone, Default)]
89#[serde(rename_all = "kebab-case")]
90pub enum SkillCategory {
91    /// Prompt templates and generators
92    #[default]
93    Template,
94    /// Analysis and transformation
95    Analyzer,
96    /// Output formatting
97    Formatter,
98    /// Integration with external tools
99    Integration,
100    /// Utility functions
101    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/// Skill hooks for pipeline integration
117#[derive(Debug, Serialize, Deserialize, Clone, Default)]
118pub struct SkillHooks {
119    /// Runs before AI generation (receives diff, can modify)
120    pub pre_gen: Option<String>,
121    /// Runs after AI generation (receives message, can modify)
122    pub post_gen: Option<String>,
123    /// Formats the final output
124    pub format: Option<String>,
125}
126
127/// Configuration option schema
128#[derive(Debug, Serialize, Deserialize, Clone)]
129pub struct SkillConfigOption {
130    /// Option type (string, bool, number)
131    pub r#type: String,
132    /// Default value
133    #[serde(default)]
134    pub default: Option<toml::Value>,
135    /// Description
136    pub description: String,
137    /// Whether it's required
138    #[serde(default)]
139    pub required: bool,
140}
141
142/// A loaded skill with its manifest and path
143#[derive(Debug, Clone)]
144pub struct Skill {
145    /// Skill manifest
146    pub manifest: SkillManifest,
147    /// Path to the skill directory
148    pub path: PathBuf,
149    /// Source/origin of the skill
150    pub source: SkillSource,
151}
152
153impl Skill {
154    /// Get the skill name
155    pub fn name(&self) -> &str {
156        &self.manifest.skill.name
157    }
158
159    /// Get the skill description
160    pub fn description(&self) -> &str {
161        &self.manifest.skill.description
162    }
163
164    /// Get the skill category
165    pub fn category(&self) -> &SkillCategory {
166        &self.manifest.skill.category
167    }
168
169    /// Get the skill source
170    pub fn source(&self) -> &SkillSource {
171        &self.source
172    }
173
174    /// Check if the skill is built-in
175    pub fn is_builtin(&self) -> bool {
176        matches!(self.source, SkillSource::Builtin)
177    }
178
179    /// Check if the skill is from the project
180    pub fn is_project_skill(&self) -> bool {
181        matches!(self.source, SkillSource::Project)
182    }
183
184    /// Check if the skill has a pre_gen hook
185    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    /// Check if the skill has a post_gen hook
194    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    /// Get the pre_gen hook path
203    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    /// Get the post_gen hook path
211    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    /// Load a prompt template from the skill if available
219    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/// Skill source/origin
233#[derive(Debug, Clone, PartialEq)]
234pub enum SkillSource {
235    /// Built-in skill shipped with rusty-commit
236    Builtin,
237    /// User-specific skill in ~/.config/rustycommit/skills/
238    User,
239    /// Project-level skill in .rco/skills/
240    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
253/// Skills manager - handles discovery and loading of skills
254pub struct SkillsManager {
255    /// User skills directory
256    user_skills_dir: PathBuf,
257    /// Project skills directory (optional)
258    project_skills_dir: Option<PathBuf>,
259    /// Loaded skills
260    skills: Vec<Skill>,
261}
262
263impl SkillsManager {
264    /// Create a new skills manager
265    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    /// Get the user skills directory
276    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    /// Get the project skills directory (if in a git repo)
289    fn project_skills_dir() -> Result<Option<PathBuf>> {
290        use crate::git;
291
292        // Try to find the git repo root
293        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    /// Check if project skills are available
303    pub fn has_project_skills(&self) -> bool {
304        self.project_skills_dir.is_some()
305    }
306
307    /// Get the project skills directory
308    pub fn project_skills_path(&self) -> Option<&Path> {
309        self.project_skills_dir.as_deref()
310    }
311
312    /// Ensure the skills directory exists
313    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    /// Discover and load all skills (user + project)
326    /// Project skills take precedence over user skills with the same name
327    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        // First, load project skills (they take precedence)
332        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        // Then, load user skills (skip duplicates)
348        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        // Sort skills by name
363        self.skills.sort_by(|a, b| a.name().cmp(b.name()));
364
365        Ok(self)
366    }
367
368    /// Load a skill from a directory
369    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    /// Get all loaded skills
389    pub fn skills(&self) -> &[Skill] {
390        &self.skills
391    }
392
393    /// Find a skill by name
394    pub fn find(&self, name: &str) -> Option<&Skill> {
395        self.skills.iter().find(|s| s.name() == name)
396    }
397
398    /// Find a skill by name (mutable)
399    pub fn find_mut(&mut self, name: &str) -> Option<&mut Skill> {
400        self.skills.iter_mut().find(|s| s.name() == name)
401    }
402
403    /// Get skills by category
404    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    /// Create a new skill from a template
412    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        // Create skill.toml
423        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        // Create prompt.md template
440        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    /// Remove a skill (only user skills can be removed)
468    pub fn remove_skill(&mut self, name: &str) -> Result<()> {
469        // Find the skill to check if it's a user skill
470        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        // Remove from loaded skills
490        self.skills.retain(|s| s.name() != name);
491
492        Ok(())
493    }
494
495    /// Get the user skills directory path
496    pub fn skills_dir(&self) -> &Path {
497        &self.user_skills_dir
498    }
499
500    /// Get or create the project skills directory
501    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
526/// Built-in skills that are always available
527pub mod builtin {
528
529    /// Get the conventional commit skill prompt
530    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    /// Get the gitmoji skill prompt
574    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
637/// External skill importers
638pub mod external {
639    use super::*;
640    use std::process::Command;
641
642    /// Available external skill sources
643    #[derive(Debug, Clone)]
644    pub enum ExternalSource {
645        /// Claude Code skills directory
646        ClaudeCode,
647        /// GitHub repository
648        GitHub {
649            owner: String,
650            repo: String,
651            path: Option<String>,
652        },
653        /// GitHub Gist
654        Gist { id: String },
655        /// Direct URL
656        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    /// Parse an external source string
673    ///
674    /// Supported formats:
675    /// - `claude-code` - Import from Claude Code skills
676    /// - `github:owner/repo` - Import from GitHub repo (looks for .rco/skills/)
677    /// - `github:owner/repo/path/to/skill` - Import specific skill from repo
678    /// - `gist:abc123` - Import from GitHub Gist
679    /// - `https://...` - Import from direct URL
680    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            // Parse github:owner/repo or github:owner/repo/path
685            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    /// Import skills from Claude Code
711    ///
712    /// Claude Code stores skills in ~/.claude/skills/
713    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                // Convert Claude Code skill to rusty-commit format
737                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 and copy files
747                convert_claude_skill(&path, &target_skill_dir, &skill_name)?;
748
749                imported.push(skill_name);
750            }
751        }
752
753        Ok(imported)
754    }
755
756    /// Convert a Claude Code skill to rusty-commit format
757    pub fn convert_claude_skill(source: &Path, target: &Path, name: &str) -> Result<()> {
758        // Claude Code skills typically have:
759        // - README.md or INSTRUCTIONS.md
760        // - Various tool definitions
761
762        // Ensure source exists
763        if !source.exists() {
764            anyhow::bail!("Source directory does not exist: {:?}", source);
765        }
766
767        // Create target directory
768        fs::create_dir_all(target)
769            .with_context(|| format!("Failed to create target directory: {:?}", target))?;
770
771        // Create skill.toml
772        let description = if source.join("README.md").exists() {
773            // Try to extract first line from README
774            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            // Claude Code uses SKILL.md
783            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        // Try to find and convert instructions to prompt.md
813        // Claude Code skills typically use SKILL.md, README.md, or INSTRUCTIONS.md
814        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                // Convert to rusty-commit prompt format
828                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            // Create a basic prompt template
841            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        // Copy any additional files (except tool definitions which are Claude-specific)
849        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            // Skip tool definition files and files we've already handled
855            if file_str.ends_with(".json") && file_str.contains("tool") {
856                continue; // Claude-specific tool definitions
857            }
858            if ["skill.toml", "prompt.md", "README.md", "INSTRUCTIONS.md"]
859                .contains(&file_str.as_ref())
860            {
861                continue;
862            }
863
864            // Copy other files
865            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    /// Import skills from GitHub
875    ///
876    /// Clones the repo temporarily and copies skills from .rco/skills/
877    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        // Create temp directory
886        let temp_dir = env::temp_dir().join(format!("rco-github-import-{}-{}", owner, repo));
887
888        // Clean up any existing temp directory
889        if temp_dir.exists() {
890            let _ = fs::remove_dir_all(&temp_dir);
891        }
892
893        // Clone the repository (shallow clone for speed)
894        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        // Determine source path
911        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        // Import skills
928        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 the skill directory
949                copy_dir_all(&path, &target_skill_dir)?;
950
951                // Update the skill.toml to mark as imported
952                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        // Clean up temp directory
968        let _ = fs::remove_dir_all(&temp_dir);
969
970        Ok(imported)
971    }
972
973    /// Import from GitHub Gist
974    pub fn import_from_gist(gist_id: &str, target_dir: &Path) -> Result<String> {
975        // Fetch gist metadata
976        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        // Use the first file as the skill name
1001        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        // Get file content
1012        if let Some(content) = file_data["content"].as_str() {
1013            // Determine if it's a skill.toml or prompt.md
1014            if filename.ends_with(".toml") {
1015                fs::write(target_skill_dir.join("skill.toml"), content)?;
1016            } else {
1017                // Assume it's a prompt template
1018                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                // Create a basic skill.toml
1026                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    /// Import from a direct URL
1049    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        // Determine skill name from URL or provided name
1064        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        // Check if content looks like TOML (skill.toml) or Markdown (prompt.md)
1080        if content.trim().starts_with('[') && content.contains("[skill]") {
1081            fs::write(target_skill_dir.join("skill.toml"), content)?;
1082        } else {
1083            // Assume it's a prompt template
1084            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            // Create a basic skill.toml
1091            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    /// Copy directory recursively
1113    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    /// List available Claude Code skills without importing
1133    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                // Try to get description from README
1157                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}