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,
14 #[command(alias = "search")]
16 Discover {
17 query: Option<String>,
19 },
20 Install {
22 spec: String,
24 },
25 Uninstall {
27 spec: String,
29 },
30 Enable {
32 spec: String,
34 },
35 Disable {
37 spec: String,
39 },
40 Sync,
42 ScanUnmanaged,
44 ImportFromApps {
46 directories: Vec<String>,
48 },
49 Info {
51 spec: String,
53 },
54 SyncMethod {
56 #[arg(value_enum)]
58 method: Option<SyncMethod>,
59 },
60 #[command(subcommand)]
62 Repos(SkillReposCommand),
63}
64
65#[derive(Subcommand)]
66pub enum SkillReposCommand {
67 List,
69 Add {
71 url: String,
73 },
74 Remove {
76 url: String,
78 },
79 Enable {
81 url: String,
83 },
84 Disable {
86 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 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}