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