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