Skip to main content

rusty_commit/commands/
skills.rs

1//! Skills command implementation
2
3use anyhow::{Context, Result};
4use colored::Colorize;
5use dirs;
6use std::fs;
7
8use crate::cli::{SkillsAction, SkillsCommand};
9use crate::skills::{SkillCategory, SkillsManager};
10
11pub async fn execute(cmd: SkillsCommand) -> Result<()> {
12    match cmd.action {
13        SkillsAction::List { category } => list_skills(category).await,
14        SkillsAction::Create { name, category, project } => create_skill(name, category, project).await,
15        SkillsAction::Show { name } => show_skill(name).await,
16        SkillsAction::Remove { name, force } => remove_skill(name, force).await,
17        SkillsAction::Open => open_skills_dir().await,
18        SkillsAction::Import { source, name } => import_skills(source, name).await,
19        SkillsAction::Available { source } => list_available_skills(source).await,
20    }
21}
22
23async fn list_skills(category_filter: Option<String>) -> Result<()> {
24    let mut manager = SkillsManager::new()?;
25    manager.discover()?;
26
27    let skills = manager.skills();
28
29    if skills.is_empty() {
30        println!("{}", "No skills found.".yellow());
31        println!();
32        println!("Create your first skill with:");
33        println!("  {}", "rco skills create my-skill".cyan());
34        return Ok(());
35    }
36
37    // Filter by category if specified
38    let filtered_skills: Vec<_> = if let Some(ref cat) = category_filter {
39        let category = parse_category(cat);
40        manager.by_category(&category).into_iter().cloned().collect()
41    } else {
42        skills.to_vec()
43    };
44
45    if filtered_skills.is_empty() {
46        println!("{}", format!("No skills found in category: {}", category_filter.unwrap()).yellow());
47        return Ok(());
48    }
49
50    println!("{}", "Available Skills".bold().underline());
51    println!();
52
53    // Group by category
54    let mut by_category: std::collections::HashMap<String, Vec<_>> = std::collections::HashMap::new();
55    for skill in &filtered_skills {
56        by_category
57            .entry(skill.category().to_string())
58            .or_default()
59            .push(skill);
60    }
61
62    // Print by category
63    let mut categories: Vec<_> = by_category.keys().collect();
64    categories.sort();
65
66    for category in categories {
67        println!("{}", format!("[{}]", category).cyan().bold());
68        for skill in by_category.get(category).unwrap() {
69            let source_marker = match skill.source() {
70                crate::skills::SkillSource::Builtin => format!(" {}", "[built-in]".dimmed()),
71                crate::skills::SkillSource::Project => format!(" {}", "[project]".yellow().dimmed()),
72                crate::skills::SkillSource::User => String::new(),
73            };
74
75            println!(
76                "  {}{}\n    {}",
77                skill.name().green(),
78                source_marker,
79                skill.description().dimmed()
80            );
81
82            // Show tags if any
83            if !skill.manifest.skill.tags.is_empty() {
84                let tags: Vec<_> = skill
85                    .manifest
86                    .skill
87                    .tags
88                    .iter()
89                    .map(|t| format!("#{}", t))
90                    .collect();
91                println!("    {}", tags.join(" ").dimmed());
92            }
93        }
94        println!();
95    }
96
97    println!(
98        "Total: {} skill{}",
99        filtered_skills.len(),
100        if filtered_skills.len() == 1 { "" } else { "s" }
101    );
102
103    Ok(())
104}
105
106async fn create_skill(name: String, category: String, project: bool) -> Result<()> {
107    let manager = SkillsManager::new()?;
108    let skill_category = parse_category(&category);
109
110    let skill_path = if project {
111        // Create project-level skill
112        let project_dir = manager.ensure_project_skills_dir()?.ok_or_else(|| {
113            anyhow::anyhow!("Not in a git repository. Cannot create project-level skill.")
114        })?;
115        
116        println!(
117            "{} Creating new {} project skill '{}'...",
118            "→".cyan(),
119            skill_category.to_string().cyan(),
120            name.green()
121        );
122        
123        let skill_dir = project_dir.join(&name);
124        if skill_dir.exists() {
125            anyhow::bail!("Project skill '{}' already exists at {}", name, skill_dir.display());
126        }
127        
128        fs::create_dir_all(&skill_dir)?;
129        create_skill_files(&skill_dir, &name, skill_category)?;
130        skill_dir
131    } else {
132        // Create user-level skill
133        println!(
134            "{} Creating new {} user skill '{}'...",
135            "→".cyan(),
136            skill_category.to_string().cyan(),
137            name.green()
138        );
139        
140        manager.create_skill(&name, skill_category)?
141    };
142
143    println!(
144        "{} Skill created at: {}",
145        "✓".green(),
146        skill_path.display().to_string().cyan()
147    );
148    println!();
149    println!("Next steps:");
150    println!(
151        "  1. Edit {} to customize your skill",
152        skill_path.join("skill.toml").display().to_string().cyan()
153    );
154    println!(
155        "  2. Modify {} with your custom prompt",
156        skill_path.join("prompt.md").display().to_string().cyan()
157    );
158    println!(
159        "  3. Use your skill: {}",
160        format!("rco --skill {}", name).cyan()
161    );
162    
163    if project {
164        println!();
165        println!("{}", "Note: Project skills are shared with everyone who clones this repo.".yellow().dimmed());
166        println!("{}", "      Make sure to commit the .rco/skills/ directory to version control.".yellow().dimmed());
167    }
168
169    Ok(())
170}
171
172/// Create skill files (skill.toml and prompt.md)
173fn create_skill_files(skill_dir: &std::path::Path, name: &str, category: crate::skills::SkillCategory) -> Result<()> {
174    use crate::skills::{SkillManifest, SkillMeta};
175    
176    // Create skill.toml
177    let manifest = SkillManifest {
178        skill: SkillMeta {
179            name: name.to_string(),
180            version: "1.0.0".to_string(),
181            description: format!("A {} skill for rusty-commit", category),
182            author: None,
183            category,
184            tags: vec![],
185        },
186        hooks: None,
187        config: None,
188    };
189
190    let manifest_content = toml::to_string_pretty(&manifest)?;
191    fs::write(skill_dir.join("skill.toml"), manifest_content)?;
192
193    // Create prompt.md template
194    let prompt_template = r#"# Custom Prompt Template
195
196You are a commit message generator. Analyze the following diff and generate a commit message.
197
198## Diff
199
200```diff
201{diff}
202```
203
204## Context
205
206{context}
207
208## Instructions
209
210Generate a commit message that:
211- Follows the conventional commit format
212- Is clear and concise
213- Describes the changes accurately
214"#;
215
216    fs::write(skill_dir.join("prompt.md"), prompt_template)?;
217    
218    Ok(())
219}
220
221async fn show_skill(name: String) -> Result<()> {
222    let mut manager = SkillsManager::new()?;
223    manager.discover()?;
224
225    let skill = manager
226        .find(&name)
227        .ok_or_else(|| anyhow::anyhow!("Skill '{}' not found", name))?;
228
229    println!("{}", skill.name().bold().underline());
230    println!();
231    println!("{}: {}", "Description".dimmed(), skill.description());
232    println!(
233        "{}: {}",
234        "Category".dimmed(),
235        skill.category().to_string().cyan()
236    );
237    println!(
238        "{}: {}",
239        "Version".dimmed(),
240        skill.manifest.skill.version
241    );
242    println!(
243        "{}: {}",
244        "Source".dimmed(),
245        skill.source().to_string().yellow()
246    );
247
248    if let Some(ref author) = skill.manifest.skill.author {
249        println!("{}: {}", "Author".dimmed(), author);
250    }
251
252    if !skill.manifest.skill.tags.is_empty() {
253        println!(
254            "{}: {}",
255            "Tags".dimmed(),
256            skill.manifest.skill.tags.join(", ")
257        );
258    }
259
260    println!(
261        "{}: {}",
262        "Location".dimmed(),
263        skill.path.display().to_string().dimmed()
264    );
265
266    // Show hooks
267    if let Some(ref hooks) = skill.manifest.hooks {
268        println!();
269        println!("{}", "Hooks".dimmed());
270        if let Some(ref pre_gen) = hooks.pre_gen {
271            println!("  {}: {}", "pre_gen".cyan(), pre_gen);
272        }
273        if let Some(ref post_gen) = hooks.post_gen {
274            println!("  {}: {}", "post_gen".cyan(), post_gen);
275        }
276        if let Some(ref format) = hooks.format {
277            println!("  {}: {}", "format".cyan(), format);
278        }
279    }
280
281    // Show prompt template preview
282    match skill.load_prompt_template() {
283        Ok(Some(template)) => {
284            println!();
285            println!("{}", "Prompt Template Preview".dimmed());
286            println!();
287            // Show first 10 lines
288            let lines: Vec<_> = template.lines().take(10).collect();
289            for line in lines {
290                println!("  {}", line.dimmed());
291            }
292            if template.lines().count() > 10 {
293                println!("  {} ...", "...".dimmed());
294            }
295        }
296        Ok(None) => {
297            println!();
298            println!("{}", "No prompt template".dimmed());
299        }
300        Err(e) => {
301            println!();
302            println!("{}: {}", "Error loading template".red(), e);
303        }
304    }
305
306    Ok(())
307}
308
309async fn remove_skill(name: String, force: bool) -> Result<()> {
310    let mut manager = SkillsManager::new()?;
311    manager.discover()?;
312
313    // Check if skill exists
314    if manager.find(&name).is_none() {
315        anyhow::bail!("Skill '{}' not found", name);
316    }
317
318    // Confirm removal unless --force
319    if !force {
320        use dialoguer::{theme::ColorfulTheme, Confirm};
321
322        let confirmed = Confirm::with_theme(&ColorfulTheme::default())
323            .with_prompt(format!("Are you sure you want to remove skill '{}'?", name))
324            .default(false)
325            .interact()?;
326
327        if !confirmed {
328            println!("{}", "Removal cancelled.".yellow());
329            return Ok(());
330        }
331    }
332
333    manager.remove_skill(&name)?;
334
335    println!("{} Skill '{}' removed.", "✓".green(), name);
336
337    Ok(())
338}
339
340async fn open_skills_dir() -> Result<()> {
341    let manager = SkillsManager::new()?;
342    manager.ensure_skills_dir()?;
343
344    let path = manager.skills_dir();
345
346    // Try to open with the default application
347    #[cfg(target_os = "macos")]
348    {
349        std::process::Command::new("open")
350            .arg(path)
351            .spawn()
352            .context("Failed to open skills directory")?;
353    }
354
355    #[cfg(target_os = "linux")]
356    {
357        // Try xdg-open first, then fall back to other options
358        let result = std::process::Command::new("xdg-open")
359            .arg(path)
360            .spawn();
361
362        if result.is_err() {
363            // Try gnome-open or kde-open
364            let _ = std::process::Command::new("gnome-open")
365                .arg(path)
366                .spawn()
367                .or_else(|_| {
368                    std::process::Command::new("kde-open")
369                        .arg(path)
370                        .spawn()
371                })
372                .context("Failed to open skills directory. Try installing xdg-open.")?;
373        }
374    }
375
376    #[cfg(target_os = "windows")]
377    {
378        std::process::Command::new("explorer")
379            .arg(path)
380            .spawn()
381            .context("Failed to open skills directory")?;
382    }
383
384    println!("{} Opened skills directory: {}", "✓".green(), path.display());
385
386    Ok(())
387}
388
389async fn import_skills(source: String, specific_name: Option<String>) -> Result<()> {
390    use crate::skills::external::{parse_source, import_from_claude_code, import_from_github, import_from_gist, import_from_url};
391    
392    let manager = SkillsManager::new()?;
393    let target_dir = manager.skills_dir();
394    
395    // Ensure skills directory exists
396    if !target_dir.exists() {
397        fs::create_dir_all(target_dir)?;
398    }
399    
400    let source = parse_source(&source)?;
401    
402    println!("{} Importing from {}...", "→".cyan(), source.to_string().cyan());
403    println!();
404    
405    let imported = match source {
406        crate::skills::external::ExternalSource::ClaudeCode => {
407            if let Some(name) = specific_name {
408                // Import specific skill
409                let claude_dir = dirs::home_dir()
410                    .context("Could not find home directory")?
411                    .join(".claude")
412                    .join("skills")
413                    .join(&name);
414                
415                if !claude_dir.exists() {
416                    anyhow::bail!("Claude Code skill '{}' not found at {:?}", name, claude_dir);
417                }
418                
419                let target = target_dir.join(&name);
420                crate::skills::external::convert_claude_skill(&claude_dir, &target, &name)?;
421                vec![name]
422            } else {
423                import_from_claude_code(target_dir)?
424            }
425        }
426        crate::skills::external::ExternalSource::GitHub { owner, repo, path } => {
427            if let Some(name) = specific_name {
428                // Import specific skill from GitHub
429                let specific_path = path.as_ref()
430                    .map(|p| format!("{}/{}", p, name))
431                    .unwrap_or_else(|| format!(".rco/skills/{}", name));
432                
433                import_from_github(&owner, &repo, Some(&specific_path), target_dir)?
434            } else {
435                import_from_github(&owner, &repo, path.as_deref(), target_dir)?
436            }
437        }
438        crate::skills::external::ExternalSource::Gist { id } => {
439            if specific_name.is_some() {
440                println!("{}", "Note: Gist import doesn't support filtering by name. Importing all...".yellow());
441            }
442            let name = import_from_gist(&id, target_dir)?;
443            vec![name]
444        }
445        crate::skills::external::ExternalSource::Url { url } => {
446            let name = import_from_url(&url, specific_name.as_deref(), target_dir)?;
447            vec![name]
448        }
449    };
450    
451    if imported.is_empty() {
452        println!("{}", "No new skills were imported (they may already exist).".yellow());
453    } else {
454        println!("{} Successfully imported {} skill(s):", "✓".green(), imported.len());
455        for name in &imported {
456            println!("  • {}", name.green());
457        }
458        println!();
459        println!("Use {} to see all available skills.", "rco skills list".cyan());
460    }
461    
462    Ok(())
463}
464
465async fn list_available_skills(source: String) -> Result<()> {
466    use crate::skills::external::list_claude_code_skills;
467    
468    match source.as_str() {
469        "claude-code" | "claude" => {
470            let skills = list_claude_code_skills()?;
471            
472            if skills.is_empty() {
473                println!("{}", "No Claude Code skills found.".yellow());
474                println!();
475                println!("Claude Code skills are stored in: ~/.claude/skills/");
476                return Ok(());
477            }
478            
479            println!("{}", "Available Claude Code Skills".bold().underline());
480            println!();
481            println!("{}", "Run 'rco skills import claude-code [name]' to import".dimmed());
482            println!();
483            
484            for (name, description) in skills {
485                println!("{} {}", "•".cyan(), name.green());
486                println!("  {}", description.dimmed());
487            }
488            
489            println!();
490            println!("To import all: {}", "rco skills import claude-code".cyan());
491            println!("To import one: {}", "rco skills import claude-code --name <skill-name>".cyan());
492        }
493        _ => {
494            anyhow::bail!("Unknown source: {}. Currently supported: claude-code", source);
495        }
496    }
497    
498    Ok(())
499}
500
501fn parse_category(s: &str) -> SkillCategory {
502    match s.to_lowercase().as_str() {
503        "analyzer" | "analysis" => SkillCategory::Analyzer,
504        "formatter" | "format" => SkillCategory::Formatter,
505        "integration" | "integrate" => SkillCategory::Integration,
506        "utility" | "util" => SkillCategory::Utility,
507        _ => SkillCategory::Template,
508    }
509}