1use 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 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 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 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 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 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 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
200fn 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 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 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 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 match skill.load_prompt_template() {
311 Ok(Some(template)) => {
312 println!();
313 println!("{}", "Prompt Template Preview".dimmed());
314 println!();
315 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 if manager.find(&name).is_none() {
343 anyhow::bail!("Skill '{}' not found", name);
344 }
345
346 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 #[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 let result = std::process::Command::new("xdg-open").arg(path).spawn();
387
388 if result.is_err() {
389 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 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 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 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}