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.hooks.as_ref().and_then(|h| {
205            h.pre_gen
206                .as_ref()
207                .map(|script| self.path.join(script))
208        })
209    }
210
211    /// Get the post_gen hook path
212    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    /// Load a prompt template from the skill if available
221    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/// Skill source/origin
234#[derive(Debug, Clone, PartialEq)]
235pub enum SkillSource {
236    /// Built-in skill shipped with rusty-commit
237    Builtin,
238    /// User-specific skill in ~/.config/rustycommit/skills/
239    User,
240    /// Project-level skill in .rco/skills/
241    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
254/// Skills manager - handles discovery and loading of skills
255pub struct SkillsManager {
256    /// User skills directory
257    user_skills_dir: PathBuf,
258    /// Project skills directory (optional)
259    project_skills_dir: Option<PathBuf>,
260    /// Loaded skills
261    skills: Vec<Skill>,
262}
263
264impl SkillsManager {
265    /// Create a new skills manager
266    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    /// Get the user skills directory
277    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    /// Get the project skills directory (if in a git repo)
290    fn project_skills_dir() -> Result<Option<PathBuf>> {
291        use crate::git;
292        
293        // Try to find the git repo root
294        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    /// Check if project skills are available
304    pub fn has_project_skills(&self) -> bool {
305        self.project_skills_dir.is_some()
306    }
307
308    /// Get the project skills directory
309    pub fn project_skills_path(&self) -> Option<&Path> {
310        self.project_skills_dir.as_deref()
311    }
312
313    /// Ensure the skills directory exists
314    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    /// Discover and load all skills (user + project)
323    /// Project skills take precedence over user skills with the same name
324    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        // First, load project skills (they take precedence)
329        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        // Then, load user skills (skip duplicates)
345        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        // Sort skills by name
360        self.skills.sort_by(|a, b| a.name().cmp(b.name()));
361
362        Ok(self)
363    }
364
365    /// Load a skill from a directory
366    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    /// Get all loaded skills
381    pub fn skills(&self) -> &[Skill] {
382        &self.skills
383    }
384
385    /// Find a skill by name
386    pub fn find(&self, name: &str) -> Option<&Skill> {
387        self.skills.iter().find(|s| s.name() == name)
388    }
389
390    /// Find a skill by name (mutable)
391    pub fn find_mut(&mut self, name: &str) -> Option<&mut Skill> {
392        self.skills.iter_mut().find(|s| s.name() == name)
393    }
394
395    /// Get skills by category
396    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    /// Create a new skill from a template
404    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        // Create skill.toml
415        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        // Create prompt.md template
432        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    /// Remove a skill (only user skills can be removed)
460    pub fn remove_skill(&mut self, name: &str) -> Result<()> {
461        // Find the skill to check if it's a user skill
462        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        // Remove from loaded skills
480        self.skills.retain(|s| s.name() != name);
481
482        Ok(())
483    }
484
485    /// Get the user skills directory path
486    pub fn skills_dir(&self) -> &Path {
487        &self.user_skills_dir
488    }
489
490    /// Get or create the project skills directory
491    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
512/// Built-in skills that are always available
513pub mod builtin {
514
515    /// Get the conventional commit skill prompt
516    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    /// Get the gitmoji skill prompt
560    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
623/// External skill importers
624pub mod external {
625    use super::*;
626    use std::process::Command;
627
628    /// Available external skill sources
629    #[derive(Debug, Clone)]
630    pub enum ExternalSource {
631        /// Claude Code skills directory
632        ClaudeCode,
633        /// GitHub repository
634        GitHub { owner: String, repo: String, path: Option<String> },
635        /// GitHub Gist
636        Gist { id: String },
637        /// Direct URL
638        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    /// Parse an external source string
653    /// 
654    /// Supported formats:
655    /// - `claude-code` - Import from Claude Code skills
656    /// - `github:owner/repo` - Import from GitHub repo (looks for .rco/skills/)
657    /// - `github:owner/repo/path/to/skill` - Import specific skill from repo
658    /// - `gist:abc123` - Import from GitHub Gist
659    /// - `https://...` - Import from direct URL
660    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            // Parse github:owner/repo or github:owner/repo/path
665            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    /// Import skills from Claude Code
687    /// 
688    /// Claude Code stores skills in ~/.claude/skills/
689    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                // Convert Claude Code skill to rusty-commit format
712                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 and copy files
722                convert_claude_skill(&path, &target_skill_dir, &skill_name)?;
723                
724                imported.push(skill_name);
725            }
726        }
727
728        Ok(imported)
729    }
730
731    /// Convert a Claude Code skill to rusty-commit format
732    pub fn convert_claude_skill(source: &Path, target: &Path, name: &str) -> Result<()> {
733        // Claude Code skills typically have:
734        // - README.md or INSTRUCTIONS.md
735        // - Various tool definitions
736        
737        // Create skill.toml
738        let description = if source.join("README.md").exists() {
739            // Try to extract first line from README
740            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        // Try to find and convert instructions to prompt.md
762        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                // Convert to rusty-commit prompt format
770                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            // Create a basic prompt template
783            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        // Copy any additional files (except tool definitions which are Claude-specific)
791        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            // Skip tool definition files and files we've already handled
797            if file_str.ends_with(".json") && file_str.contains("tool") {
798                continue; // Claude-specific tool definitions
799            }
800            if ["skill.toml", "prompt.md", "README.md", "INSTRUCTIONS.md"].contains(&file_str.as_ref()) {
801                continue;
802            }
803            
804            // Copy other files
805            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    /// Import skills from GitHub
815    /// 
816    /// Clones the repo temporarily and copies skills from .rco/skills/
817    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        // Create temp directory
826        let temp_dir = env::temp_dir().join(format!("rco-github-import-{}-{}", owner, repo));
827        
828        // Clean up any existing temp directory
829        if temp_dir.exists() {
830            let _ = fs::remove_dir_all(&temp_dir);
831        }
832
833        // Clone the repository (shallow clone for speed)
834        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        // Determine source path
850        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        // Import skills
862        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 the skill directory
882                copy_dir_all(&path, &target_skill_dir)?;
883                
884                // Update the skill.toml to mark as imported
885                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        // Clean up temp directory
901        let _ = fs::remove_dir_all(&temp_dir);
902
903        Ok(imported)
904    }
905
906    /// Import from GitHub Gist
907    pub fn import_from_gist(gist_id: &str, target_dir: &Path) -> Result<String> {
908        // Fetch gist metadata
909        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        // Use the first file as the skill name
933        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        // Get file content
944        if let Some(content) = file_data["content"].as_str() {
945            // Determine if it's a skill.toml or prompt.md
946            if filename.ends_with(".toml") {
947                fs::write(target_skill_dir.join("skill.toml"), content)?;
948            } else {
949                // Assume it's a prompt template
950                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                // Create a basic skill.toml
958                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    /// Import from a direct URL
978    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        // Determine skill name from URL or provided name
993        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        // Check if content looks like TOML (skill.toml) or Markdown (prompt.md)
1008        if content.trim().starts_with('[') && content.contains("[skill]") {
1009            fs::write(target_skill_dir.join("skill.toml"), content)?;
1010        } else {
1011            // Assume it's a prompt template
1012            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            // Create a basic skill.toml
1019            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    /// Copy directory recursively
1038    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    /// List available Claude Code skills without importing
1058    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                // Try to get description from README
1081                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}