Skip to main content

cc_switch_lib/cli/commands/
skills.rs

1use clap::Subcommand;
2use std::future::Future;
3
4use crate::app_config::AppType;
5use crate::cli::ui::{create_table, highlight, info, success};
6use crate::error::AppError;
7use crate::services::skill::{SkillRepo, SyncMethod};
8use crate::services::SkillService;
9
10#[derive(Subcommand)]
11pub enum SkillsCommand {
12    /// List installed skills (from SSOT + database state)
13    List,
14    /// Discover available skills (from enabled repos)
15    #[command(alias = "search")]
16    Discover {
17        /// Optional query filter (matches name/directory)
18        query: Option<String>,
19    },
20    /// Install a skill (SSOT -> app skills dir)
21    Install {
22        /// Skill directory name or full key (owner/name:directory)
23        spec: String,
24    },
25    /// Uninstall a skill (remove from SSOT and app dirs)
26    Uninstall {
27        /// Skill directory or id
28        spec: String,
29    },
30    /// Enable a skill for the selected app
31    Enable {
32        /// Skill directory or id
33        spec: String,
34    },
35    /// Disable a skill for the selected app
36    Disable {
37        /// Skill directory or id
38        spec: String,
39    },
40    /// Sync enabled skills to app skills dirs
41    Sync,
42    /// Scan unmanaged skills in app skills dirs
43    ScanUnmanaged,
44    /// Import unmanaged skills from app skills dirs into SSOT
45    ImportFromApps {
46        /// One or more skill directories to import
47        directories: Vec<String>,
48    },
49    /// Show skill information
50    Info {
51        /// Skill directory or id
52        spec: String,
53    },
54    /// Get or set the skills sync method (auto|symlink|copy)
55    SyncMethod {
56        /// Optional method to set (omit to show current)
57        #[arg(value_enum)]
58        method: Option<SyncMethod>,
59    },
60    /// Manage skill repositories
61    #[command(subcommand)]
62    Repos(SkillReposCommand),
63}
64
65#[derive(Subcommand)]
66pub enum SkillReposCommand {
67    /// List all repositories
68    List,
69    /// Add a repository
70    Add {
71        /// Repository (GitHub URL or owner/name[@branch])
72        url: String,
73    },
74    /// Remove a repository
75    Remove {
76        /// Repository (GitHub URL or owner/name)
77        url: String,
78    },
79    /// Enable a repository without changing its branch
80    Enable {
81        /// Repository (GitHub URL or owner/name)
82        url: String,
83    },
84    /// Disable a repository without changing its branch
85    Disable {
86        /// Repository (GitHub URL or owner/name)
87        url: String,
88    },
89}
90
91pub fn execute(cmd: SkillsCommand, app: Option<AppType>) -> Result<(), AppError> {
92    let app_type = app.clone().unwrap_or(AppType::Claude);
93
94    match cmd {
95        SkillsCommand::List => list_installed(),
96        SkillsCommand::Discover { query } => discover_skills(query.as_deref()),
97        SkillsCommand::Install { spec } => install_skill(&app_type, &spec),
98        SkillsCommand::Uninstall { spec } => uninstall_skill(&spec),
99        SkillsCommand::Enable { spec } => toggle_skill(&app_type, &spec, true),
100        SkillsCommand::Disable { spec } => toggle_skill(&app_type, &spec, false),
101        SkillsCommand::Sync => sync_skills(app.as_ref()),
102        SkillsCommand::ScanUnmanaged => scan_unmanaged(),
103        SkillsCommand::ImportFromApps { directories } => import_from_apps(directories),
104        SkillsCommand::Info { spec } => show_skill_info(&spec),
105        SkillsCommand::SyncMethod { method } => sync_method(method),
106        SkillsCommand::Repos(repos_cmd) => execute_repos(repos_cmd),
107    }
108}
109
110fn run_async<T>(fut: impl Future<Output = Result<T, AppError>>) -> Result<T, AppError> {
111    tokio::runtime::Builder::new_current_thread()
112        .enable_all()
113        .build()
114        .map_err(|e| AppError::Message(format!("Failed to create runtime: {e}")))?
115        .block_on(fut)
116}
117
118fn list_installed() -> Result<(), AppError> {
119    let skills = SkillService::list_installed()?;
120
121    if skills.is_empty() {
122        println!("{}", info("No installed skills found."));
123        return Ok(());
124    }
125
126    let mut table = create_table();
127    table.set_header(vec![
128        "Directory",
129        "Name",
130        "Claude",
131        "Codex",
132        "Gemini",
133        "OpenCode",
134    ]);
135    for skill in skills {
136        table.add_row(vec![
137            skill.directory,
138            skill.name,
139            if skill.apps.claude { "✓" } else { " " }.to_string(),
140            if skill.apps.codex { "✓" } else { " " }.to_string(),
141            if skill.apps.gemini { "✓" } else { " " }.to_string(),
142            if skill.apps.opencode { "✓" } else { " " }.to_string(),
143        ]);
144    }
145
146    println!("{}", table);
147    Ok(())
148}
149
150fn discover_skills(query: Option<&str>) -> Result<(), AppError> {
151    let service = SkillService::new()?;
152    let mut skills = run_async(service.list_skills())?;
153
154    if let Some(query) = query.map(str::trim).filter(|q| !q.is_empty()) {
155        let q = query.to_lowercase();
156        skills.retain(|s| {
157            s.name.to_lowercase().contains(&q) || s.directory.to_lowercase().contains(&q)
158        });
159    }
160
161    if skills.is_empty() {
162        println!("{}", info("No skills found."));
163        return Ok(());
164    }
165
166    let mut table = create_table();
167    table.set_header(vec!["", "Directory", "Name"]);
168    for skill in skills {
169        table.add_row(vec![
170            if skill.installed { "✓" } else { " " }.to_string(),
171            skill.directory,
172            skill.name,
173        ]);
174    }
175    println!("{}", table);
176    Ok(())
177}
178
179fn install_skill(app_type: &AppType, spec: &str) -> Result<(), AppError> {
180    let service = SkillService::new()?;
181    let installed = run_async(service.install(spec, app_type))?;
182    println!(
183        "{}",
184        success(&format!(
185            "✓ Installed skill '{}' (enabled for {})",
186            installed.directory,
187            app_type.as_str()
188        ))
189    );
190    Ok(())
191}
192
193fn uninstall_skill(spec: &str) -> Result<(), AppError> {
194    SkillService::uninstall(spec)?;
195    println!("{}", success(&format!("✓ Uninstalled skill '{spec}'")));
196    Ok(())
197}
198
199fn toggle_skill(app_type: &AppType, spec: &str, enabled: bool) -> Result<(), AppError> {
200    SkillService::toggle_app(spec, app_type, enabled)?;
201    println!(
202        "{}",
203        success(&format!(
204            "✓ {} '{}' for {}",
205            if enabled { "Enabled" } else { "Disabled" },
206            spec,
207            app_type.as_str()
208        ))
209    );
210    Ok(())
211}
212
213fn sync_skills(app: Option<&AppType>) -> Result<(), AppError> {
214    SkillService::sync_all_enabled(app)?;
215    println!("{}", success("✓ Skills synced successfully"));
216    Ok(())
217}
218
219fn scan_unmanaged() -> Result<(), AppError> {
220    let skills = SkillService::scan_unmanaged()?;
221    if skills.is_empty() {
222        println!("{}", info("No unmanaged skills found."));
223        return Ok(());
224    }
225
226    let mut table = create_table();
227    table.set_header(vec!["Directory", "Found In", "Name"]);
228    for s in skills {
229        table.add_row(vec![s.directory, s.found_in.join(", "), s.name]);
230    }
231    println!("{}", table);
232    Ok(())
233}
234
235fn import_from_apps(directories: Vec<String>) -> Result<(), AppError> {
236    if directories.is_empty() {
237        return Err(AppError::InvalidInput(
238            "Please provide at least one directory".to_string(),
239        ));
240    }
241
242    let imported = SkillService::import_from_apps(directories)?;
243    println!(
244        "{}",
245        success(&format!("✓ Imported {} skill(s) into SSOT", imported.len()))
246    );
247    Ok(())
248}
249
250fn show_skill_info(spec: &str) -> Result<(), AppError> {
251    let index = SkillService::load_index()?;
252
253    let record = index
254        .skills
255        .values()
256        .find(|s| s.directory.eq_ignore_ascii_case(spec) || s.id.eq_ignore_ascii_case(spec))
257        .ok_or_else(|| AppError::Message(format!("Skill not found: {spec}")))?;
258
259    println!("{}", highlight("Skill"));
260    println!("Directory: {}", record.directory);
261    println!("Name:      {}", record.name);
262    if let Some(desc) = record
263        .description
264        .as_deref()
265        .filter(|s| !s.trim().is_empty())
266    {
267        println!("Desc:      {}", desc);
268    }
269    println!(
270        "Enabled:   claude={} codex={} gemini={} opencode={}",
271        record.apps.claude, record.apps.codex, record.apps.gemini, record.apps.opencode
272    );
273
274    Ok(())
275}
276
277fn execute_repos(cmd: SkillReposCommand) -> Result<(), AppError> {
278    match cmd {
279        SkillReposCommand::List => list_repos(),
280        SkillReposCommand::Add { url } => add_repo(&url),
281        SkillReposCommand::Remove { url } => remove_repo(&url),
282        SkillReposCommand::Enable { url } => set_repo_enabled(&url, true),
283        SkillReposCommand::Disable { url } => set_repo_enabled(&url, false),
284    }
285}
286
287fn list_repos() -> Result<(), AppError> {
288    let repos = SkillService::list_repos()?;
289
290    if repos.is_empty() {
291        println!("{}", info("No skill repos configured."));
292        return Ok(());
293    }
294
295    let mut table = create_table();
296    table.set_header(vec!["Enabled", "Repo", "Branch"]);
297    for repo in repos {
298        table.add_row(vec![
299            if repo.enabled { "✓" } else { " " }.to_string(),
300            format!("{}/{}", repo.owner, repo.name),
301            repo.branch,
302        ]);
303    }
304    println!("{}", table);
305    Ok(())
306}
307
308fn add_repo(_url: &str) -> Result<(), AppError> {
309    let repo = parse_repo_spec(_url)?;
310    SkillService::upsert_repo(repo)?;
311    println!("{}", success("✓ Repository added."));
312    Ok(())
313}
314
315fn remove_repo(_url: &str) -> Result<(), AppError> {
316    let repo = parse_repo_spec(_url)?;
317    SkillService::remove_repo(&repo.owner, &repo.name)?;
318    println!("{}", success("✓ Repository removed."));
319    Ok(())
320}
321
322fn set_repo_enabled(url: &str, enabled: bool) -> Result<(), AppError> {
323    let repo = parse_repo_spec(url)?;
324    let existing = SkillService::list_repos()?
325        .into_iter()
326        .find(|candidate| candidate.owner == repo.owner && candidate.name == repo.name)
327        .ok_or_else(|| {
328            AppError::Message(format!(
329                "Repository not found: {}/{}",
330                repo.owner, repo.name
331            ))
332        })?;
333
334    SkillService::upsert_repo(repo_with_enabled(existing, enabled))?;
335    println!(
336        "{}",
337        success(&format!(
338            "✓ Repository {}.",
339            if enabled { "enabled" } else { "disabled" }
340        ))
341    );
342    Ok(())
343}
344
345fn repo_with_enabled(mut repo: SkillRepo, enabled: bool) -> SkillRepo {
346    repo.enabled = enabled;
347    repo
348}
349
350fn sync_method(method: Option<SyncMethod>) -> Result<(), AppError> {
351    match method {
352        Some(method) => {
353            SkillService::set_sync_method(method)?;
354            println!(
355                "{}",
356                success(&format!("✓ Skill sync method set to {method:?}"))
357            );
358        }
359        None => {
360            let method = SkillService::get_sync_method()?;
361            println!("{}", highlight("Skill Sync Method"));
362            println!("{method:?}");
363        }
364    }
365    Ok(())
366}
367
368fn parse_repo_spec(raw: &str) -> Result<SkillRepo, AppError> {
369    let raw = raw.trim().trim_end_matches('/');
370    if raw.is_empty() {
371        return Err(AppError::InvalidInput(
372            "Repository cannot be empty".to_string(),
373        ));
374    }
375
376    // Allow: https://github.com/owner/name or owner/name[@branch]
377    let without_prefix = raw
378        .strip_prefix("https://github.com/")
379        .or_else(|| raw.strip_prefix("http://github.com/"))
380        .unwrap_or(raw);
381
382    let without_git = without_prefix.trim_end_matches(".git");
383
384    let (path, branch) = if let Some((left, right)) = without_git.rsplit_once('@') {
385        (left, Some(right))
386    } else {
387        (without_git, None)
388    };
389
390    let Some((owner, name)) = path.split_once('/') else {
391        return Err(AppError::InvalidInput(
392            "Invalid repo format. Use owner/name or https://github.com/owner/name".to_string(),
393        ));
394    };
395
396    Ok(SkillRepo {
397        owner: owner.to_string(),
398        name: name.to_string(),
399        branch: branch.unwrap_or("main").to_string(),
400        enabled: true,
401    })
402}
403
404#[cfg(test)]
405mod tests {
406    use super::{parse_repo_spec, repo_with_enabled};
407    use crate::services::skill::SkillRepo;
408
409    #[test]
410    fn parse_repo_spec_supports_plain_owner_repo() {
411        let repo = parse_repo_spec("foo/bar").expect("plain owner/repo should parse");
412
413        assert_eq!(repo.owner, "foo");
414        assert_eq!(repo.name, "bar");
415        assert_eq!(repo.branch, "main");
416        assert!(repo.enabled);
417    }
418
419    #[test]
420    fn parse_repo_spec_supports_branch_suffix() {
421        let repo = parse_repo_spec("foo/bar@dev").expect("branch suffix should parse");
422
423        assert_eq!(repo.owner, "foo");
424        assert_eq!(repo.name, "bar");
425        assert_eq!(repo.branch, "dev");
426        assert!(repo.enabled);
427    }
428
429    #[test]
430    fn repo_with_enabled_preserves_branch_and_identity() {
431        let repo = SkillRepo {
432            owner: "foo".to_string(),
433            name: "bar".to_string(),
434            branch: "release".to_string(),
435            enabled: true,
436        };
437
438        let updated = repo_with_enabled(repo, false);
439
440        assert_eq!(updated.owner, "foo");
441        assert_eq!(updated.name, "bar");
442        assert_eq!(updated.branch, "release");
443        assert!(!updated.enabled);
444    }
445}