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 { 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 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 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 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 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 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 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
172fn create_skill_files(skill_dir: &std::path::Path, name: &str, category: crate::skills::SkillCategory) -> Result<()> {
174 use crate::skills::{SkillManifest, SkillMeta};
175
176 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 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 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 match skill.load_prompt_template() {
283 Ok(Some(template)) => {
284 println!();
285 println!("{}", "Prompt Template Preview".dimmed());
286 println!();
287 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 if manager.find(&name).is_none() {
315 anyhow::bail!("Skill '{}' not found", name);
316 }
317
318 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 #[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 let result = std::process::Command::new("xdg-open")
359 .arg(path)
360 .spawn();
361
362 if result.is_err() {
363 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 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 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 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}