1mod discovery;
8
9use chrono::{DateTime, Utc};
10use futures::future::join_all;
11use reqwest::Client;
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use std::fs;
15use std::path::{Path, PathBuf};
16use tokio::time::timeout;
17
18use crate::app_config::AppType;
19pub use crate::app_config::{InstalledSkill, SkillApps, UnmanagedSkill};
20use crate::config::get_app_config_dir;
21use crate::database::Database;
22use crate::error::{format_skill_error, AppError};
23
24const SKILLS_INDEX_VERSION: u32 = 1;
25
26fn default_skills_index_version() -> u32 {
27 SKILLS_INDEX_VERSION
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SkillRepo {
37 pub owner: String,
39 pub name: String,
41 pub branch: String,
43 pub enabled: bool,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SkillState {
50 pub installed: bool,
52 #[serde(rename = "installedAt")]
54 pub installed_at: DateTime<Utc>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct SkillStore {
60 pub skills: HashMap<String, SkillState>,
62 pub repos: Vec<SkillRepo>,
64}
65
66impl Default for SkillStore {
67 fn default() -> Self {
68 SkillStore {
69 skills: HashMap::new(),
70 repos: vec![
72 SkillRepo {
73 owner: "anthropics".to_string(),
74 name: "skills".to_string(),
75 branch: "main".to_string(),
76 enabled: true,
77 },
78 SkillRepo {
79 owner: "ComposioHQ".to_string(),
80 name: "awesome-claude-skills".to_string(),
81 branch: "master".to_string(),
82 enabled: true,
83 },
84 SkillRepo {
85 owner: "cexll".to_string(),
86 name: "myclaude".to_string(),
87 branch: "master".to_string(),
88 enabled: true,
89 },
90 SkillRepo {
91 owner: "JimLiu".to_string(),
92 name: "baoyu-skills".to_string(),
93 branch: "main".to_string(),
94 enabled: true,
95 },
96 ],
97 }
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
107#[serde(rename_all = "lowercase")]
108pub enum SyncMethod {
109 #[default]
111 Auto,
112 Symlink,
114 Copy,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(rename_all = "camelCase")]
121pub struct SkillsIndex {
122 #[serde(default = "default_skills_index_version")]
123 pub version: u32,
124 #[serde(default)]
125 pub sync_method: SyncMethod,
126 #[serde(default)]
127 pub repos: Vec<SkillRepo>,
128 #[serde(default)]
130 pub skills: HashMap<String, InstalledSkill>,
131 #[serde(default)]
133 pub ssot_migration_pending: bool,
134}
135
136impl Default for SkillsIndex {
137 fn default() -> Self {
138 Self {
139 version: SKILLS_INDEX_VERSION,
140 sync_method: SyncMethod::default(),
141 repos: SkillStore::default().repos,
142 skills: HashMap::new(),
143 ssot_migration_pending: false,
144 }
145 }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct DiscoverableSkill {
156 pub key: String,
158 pub name: String,
159 pub description: String,
160 pub directory: String,
162 #[serde(rename = "readmeUrl")]
163 pub readme_url: Option<String>,
164 #[serde(rename = "repoOwner")]
165 pub repo_owner: String,
166 #[serde(rename = "repoName")]
167 pub repo_name: String,
168 #[serde(rename = "repoBranch")]
169 pub repo_branch: String,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct Skill {
176 pub key: String,
177 pub name: String,
178 pub description: String,
179 pub directory: String,
180 #[serde(rename = "readmeUrl")]
181 pub readme_url: Option<String>,
182 pub installed: bool,
183 #[serde(rename = "repoOwner")]
184 pub repo_owner: Option<String>,
185 #[serde(rename = "repoName")]
186 pub repo_name: Option<String>,
187 #[serde(rename = "repoBranch")]
188 pub repo_branch: Option<String>,
189}
190
191#[derive(Debug, Clone, Deserialize)]
193pub struct SkillMetadata {
194 pub name: Option<String>,
195 pub description: Option<String>,
196}
197
198#[derive(Deserialize)]
199struct AgentsLockFile {
200 skills: HashMap<String, AgentsLockSkill>,
201}
202
203#[derive(Deserialize)]
204#[serde(rename_all = "camelCase")]
205struct AgentsLockSkill {
206 source: Option<String>,
207 source_type: Option<String>,
208 source_url: Option<String>,
209 skill_path: Option<String>,
210 branch: Option<String>,
211 source_branch: Option<String>,
212}
213
214#[derive(Debug, Clone)]
215struct LockRepoInfo {
216 owner: String,
217 repo: String,
218 skill_path: Option<String>,
219 branch: Option<String>,
220}
221
222fn normalize_optional_branch(branch: Option<String>) -> Option<String> {
223 branch.and_then(|branch| {
224 let trimmed = branch.trim();
225 if trimmed.is_empty() {
226 None
227 } else {
228 Some(trimmed.to_string())
229 }
230 })
231}
232
233fn parse_branch_from_source_url(source_url: Option<&str>) -> Option<String> {
234 let source_url = source_url?.trim();
235 if source_url.is_empty() {
236 return None;
237 }
238
239 if let Some((_, after_tree)) = source_url.split_once("/tree/") {
240 let branch = after_tree.split('/').next()?.trim();
241 if !branch.is_empty() {
242 return Some(branch.to_string());
243 }
244 }
245
246 if let Some((_, fragment)) = source_url.split_once('#') {
247 let branch = fragment.split('&').next()?.trim();
248 if !branch.is_empty() {
249 return Some(branch.to_string());
250 }
251 }
252
253 if let Some((_, query)) = source_url.split_once('?') {
254 for pair in query.split('&') {
255 let Some((key, value)) = pair.split_once('=') else {
256 continue;
257 };
258 if matches!(key, "branch" | "ref") {
259 let branch = value.trim();
260 if !branch.is_empty() {
261 return Some(branch.to_string());
262 }
263 }
264 }
265 }
266
267 None
268}
269
270fn get_agents_skills_dir() -> Option<PathBuf> {
271 dirs::home_dir()
272 .map(|home| home.join(".agents").join("skills"))
273 .filter(|path| path.exists())
274}
275
276fn parse_agents_lock() -> HashMap<String, LockRepoInfo> {
277 let path = match dirs::home_dir() {
278 Some(home) => home.join(".agents").join(".skill-lock.json"),
279 None => return HashMap::new(),
280 };
281
282 let content = match fs::read_to_string(&path) {
283 Ok(content) => content,
284 Err(_) => return HashMap::new(),
285 };
286
287 let lock: AgentsLockFile = match serde_json::from_str(&content) {
288 Ok(lock) => lock,
289 Err(_) => return HashMap::new(),
290 };
291
292 lock.skills
293 .into_iter()
294 .filter_map(|(name, skill)| {
295 let source = skill.source?;
296 if skill.source_type.as_deref() != Some("github") {
297 return None;
298 }
299 let (owner, repo) = source.split_once('/')?;
300 let branch = normalize_optional_branch(skill.branch)
301 .or_else(|| normalize_optional_branch(skill.source_branch))
302 .or_else(|| parse_branch_from_source_url(skill.source_url.as_deref()));
303 Some((
304 name,
305 LockRepoInfo {
306 owner: owner.to_string(),
307 repo: repo.to_string(),
308 skill_path: skill.skill_path,
309 branch,
310 },
311 ))
312 })
313 .collect()
314}
315
316fn build_repo_info_from_lock(
317 lock: &HashMap<String, LockRepoInfo>,
318 dir_name: &str,
319) -> (
320 String,
321 Option<String>,
322 Option<String>,
323 Option<String>,
324 Option<String>,
325) {
326 match lock.get(dir_name) {
327 Some(info) => {
328 let branch = info.branch.clone();
329 let url_branch = branch.clone().unwrap_or_else(|| "HEAD".to_string());
330 let fallback = format!("{dir_name}/SKILL.md");
331 let doc_path = info.skill_path.as_deref().unwrap_or(&fallback);
332 let url = Some(SkillService::build_skill_doc_url(
333 &info.owner,
334 &info.repo,
335 &url_branch,
336 doc_path,
337 ));
338 (
339 format!("{}/{}:{dir_name}", info.owner, info.repo),
340 Some(info.owner.clone()),
341 Some(info.repo.clone()),
342 branch,
343 url,
344 )
345 }
346 None => (format!("local:{dir_name}"), None, None, None, None),
347 }
348}
349
350fn merge_repos_from_lock(
351 repos: &mut Vec<SkillRepo>,
352 lock: &HashMap<String, LockRepoInfo>,
353 directories: impl Iterator<Item = impl AsRef<str>>,
354) {
355 let mut existing: HashSet<(String, String)> = repos
356 .iter()
357 .map(|repo| (repo.owner.clone(), repo.name.clone()))
358 .collect();
359
360 for dir_name in directories {
361 if let Some(info) = lock.get(dir_name.as_ref()) {
362 let key = (info.owner.clone(), info.repo.clone());
363 if existing.insert(key) {
364 repos.push(SkillRepo {
365 owner: info.owner.clone(),
366 name: info.repo.clone(),
367 branch: info.branch.clone().unwrap_or_else(|| "HEAD".to_string()),
368 enabled: true,
369 });
370 }
371 }
372 }
373}
374
375pub struct SkillService {
380 http_client: Client,
381}
382
383impl SkillService {
384 fn app_supports_skills(app: &AppType) -> bool {
385 !matches!(app, AppType::OpenClaw)
386 }
387
388 fn supported_skill_apps() -> impl Iterator<Item = AppType> {
389 [
390 AppType::Claude,
391 AppType::Codex,
392 AppType::Gemini,
393 AppType::OpenCode,
394 AppType::Hermes,
395 ]
396 .into_iter()
397 }
398
399 pub fn new() -> Result<Self, AppError> {
400 let http_client = Client::builder()
401 .user_agent("cc-switch-tui")
402 .timeout(std::time::Duration::from_secs(10))
403 .build()
404 .map_err(|e| {
405 AppError::localized(
406 "skills.http_client_failed",
407 format!("创建 HTTP 客户端失败: {e}"),
408 format!("Failed to create HTTP client: {e}"),
409 )
410 })?;
411
412 Ok(Self { http_client })
413 }
414
415 pub fn get_ssot_dir() -> Result<PathBuf, AppError> {
420 let dir = get_app_config_dir().join("skills");
421 fs::create_dir_all(&dir).map_err(|e| AppError::io(&dir, e))?;
422 Ok(dir)
423 }
424
425 pub fn get_app_skills_dir(app: &AppType) -> Result<PathBuf, AppError> {
426 match app {
428 AppType::Claude => {
429 if let Some(custom) = crate::settings::get_claude_override_dir() {
430 return Ok(custom.join("skills"));
431 }
432 }
433 AppType::Codex => {
434 if let Some(custom) = crate::settings::get_codex_override_dir() {
435 return Ok(custom.join("skills"));
436 }
437 }
438 AppType::Gemini => {
439 if let Some(custom) = crate::settings::get_gemini_override_dir() {
440 return Ok(custom.join("skills"));
441 }
442 }
443 AppType::OpenCode => {
444 if let Some(custom) = crate::settings::get_opencode_override_dir() {
445 return Ok(custom.join("skills"));
446 }
447 }
448 AppType::OpenClaw => {
449 if let Some(custom) = crate::settings::get_openclaw_override_dir() {
450 return Ok(custom.join("skills"));
451 }
452 }
453 AppType::Hermes => {
454 if let Some(custom) = crate::settings::get_hermes_override_dir() {
455 return Ok(custom.join("skills"));
456 }
457 }
458 }
459
460 let home = dirs::home_dir().ok_or_else(|| {
461 AppError::Message(format_skill_error(
462 "GET_HOME_DIR_FAILED",
463 &[],
464 Some("checkPermission"),
465 ))
466 })?;
467
468 Ok(match app {
469 AppType::Claude => home.join(".claude").join("skills"),
470 AppType::Codex => home.join(".codex").join("skills"),
471 AppType::Gemini => home.join(".gemini").join("skills"),
472 AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
473 AppType::OpenClaw => home.join(".openclaw").join("skills"),
474 AppType::Hermes => home.join(".hermes").join("skills"),
475 })
476 }
477
478 pub fn load_index() -> Result<SkillsIndex, AppError> {
483 let db = Database::init()?;
484
485 let _ = db.init_default_skill_repos();
487
488 let repos = db.get_skill_repos()?;
489 let installed = db.get_all_installed_skills()?;
490 let skills: HashMap<String, InstalledSkill> = installed
491 .into_values()
492 .map(|skill| (skill.directory.clone(), skill))
493 .collect();
494
495 let sync_method = crate::settings::get_skill_sync_method();
496 let ssot_migration_pending = db
497 .get_setting("skills_ssot_migration_pending")?
498 .is_some_and(|v| v == "true" || v == "1");
499
500 Ok(SkillsIndex {
501 version: SKILLS_INDEX_VERSION,
502 sync_method,
503 repos,
504 skills,
505 ssot_migration_pending,
506 })
507 }
508
509 pub fn save_index(index: &SkillsIndex) -> Result<(), AppError> {
510 let db = Database::init()?;
511
512 crate::settings::set_skill_sync_method(index.sync_method)?;
513
514 for repo in &index.repos {
515 db.save_skill_repo(repo)?;
516 }
517
518 for skill in index.skills.values() {
519 db.save_skill(skill)?;
520 }
521
522 Ok(())
523 }
524
525 pub fn migrate_ssot_if_pending(index: &mut SkillsIndex) -> Result<usize, AppError> {
530 if !index.ssot_migration_pending {
531 return Ok(0);
532 }
533
534 let db = Database::init()?;
535 let ssot_dir = Self::get_ssot_dir()?;
536 let mut created = 0usize;
537
538 if !index.skills.is_empty() {
544 for (directory, record) in index.skills.iter_mut() {
545 let dest = ssot_dir.join(directory);
546 if dest.exists() {
547 continue;
548 }
549
550 let mut candidates: Vec<AppType> = Self::supported_skill_apps()
552 .into_iter()
553 .filter(|app| record.apps.is_enabled_for(app))
554 .collect();
555 if candidates.is_empty() {
556 candidates = Self::supported_skill_apps().collect();
557 }
558
559 let mut source: Option<PathBuf> = None;
560 for app in candidates {
561 let app_dir = match Self::get_app_skills_dir(&app) {
562 Ok(d) => d,
563 Err(_) => continue,
564 };
565 let skill_path = app_dir.join(directory);
566 if skill_path.exists() {
567 source = Some(skill_path);
568 break;
569 }
570 }
571
572 match source {
573 Some(source) => {
574 Self::copy_dir_recursive(&source, &dest)?;
575 created += 1;
576
577 let skill_md = dest.join("SKILL.md");
579 if skill_md.exists() {
580 if let Ok(meta) = Self::parse_skill_metadata_static(&skill_md) {
581 if record.name.trim().is_empty()
582 || record.name.eq_ignore_ascii_case(&record.directory)
583 {
584 record.name =
585 meta.name.unwrap_or_else(|| record.directory.clone());
586 }
587 if record.description.is_none() {
588 record.description = meta.description;
589 }
590 }
591 }
592 }
593 None => {
594 log::warn!(
595 "SSOT 迁移: 未找到技能目录来源(directory={directory}),已跳过复制"
596 );
597 }
598 }
599 }
600
601 index.ssot_migration_pending = false;
602 let _ = db.set_setting("skills_ssot_migration_pending", "false");
603 Self::save_index(index)?;
604 return Ok(created);
605 }
606
607 let mut discovered: HashMap<String, SkillApps> = HashMap::new();
608
609 for app in Self::supported_skill_apps() {
610 let app_dir = match Self::get_app_skills_dir(&app) {
611 Ok(d) => d,
612 Err(_) => continue,
613 };
614 if !app_dir.exists() {
615 continue;
616 }
617
618 for entry in fs::read_dir(&app_dir).map_err(|e| AppError::io(&app_dir, e))? {
619 let entry = entry.map_err(|e| AppError::io(&app_dir, e))?;
620 let path = entry.path();
621 if !path.is_dir() {
622 continue;
623 }
624
625 let dir_name = entry.file_name().to_string_lossy().to_string();
626 if dir_name.starts_with('.') {
627 continue;
628 }
629
630 let ssot_path = ssot_dir.join(&dir_name);
632 if !ssot_path.exists() {
633 Self::copy_dir_recursive(&path, &ssot_path)?;
634 }
635
636 discovered
637 .entry(dir_name)
638 .or_default()
639 .set_enabled_for(&app, true);
640 }
641 }
642
643 for (directory, apps) in discovered {
645 let ssot_path = ssot_dir.join(&directory);
646 let skill_md = ssot_path.join("SKILL.md");
647 let (name, description) = if skill_md.exists() {
648 match Self::parse_skill_metadata_static(&skill_md) {
649 Ok(meta) => (
650 meta.name.unwrap_or_else(|| directory.clone()),
651 meta.description,
652 ),
653 Err(_) => (directory.clone(), None),
654 }
655 } else {
656 (directory.clone(), None)
657 };
658
659 match index.skills.get_mut(&directory) {
660 Some(existing) => {
661 existing.apps.merge_enabled(&apps);
662 if existing.name.trim().is_empty() {
663 existing.name = name;
664 }
665 if existing.description.is_none() {
666 existing.description = description;
667 }
668 }
669 None => {
670 index.skills.insert(
671 directory.clone(),
672 InstalledSkill {
673 id: format!("local:{directory}"),
674 name,
675 description,
676 directory: directory.clone(),
677 readme_url: None,
678 repo_owner: None,
679 repo_name: None,
680 repo_branch: None,
681 apps,
682 installed_at: Utc::now().timestamp(),
683 },
684 );
685 created += 1;
686 }
687 }
688 }
689
690 index.ssot_migration_pending = false;
691 let _ = db.set_setting("skills_ssot_migration_pending", "false");
692 Self::save_index(index)?;
693 Ok(created)
694 }
695
696 #[cfg(unix)]
701 fn create_symlink(src: &Path, dest: &Path) -> Result<(), AppError> {
702 std::os::unix::fs::symlink(src, dest).map_err(|e| AppError::IoContext {
703 context: format!("创建符号链接失败 ({} -> {})", src.display(), dest.display()),
704 source: e,
705 })
706 }
707
708 #[cfg(windows)]
709 fn create_symlink(src: &Path, dest: &Path) -> Result<(), AppError> {
710 std::os::windows::fs::symlink_dir(src, dest).map_err(|e| AppError::IoContext {
711 context: format!("创建符号链接失败 ({} -> {})", src.display(), dest.display()),
712 source: e,
713 })
714 }
715
716 fn is_symlink(path: &Path) -> bool {
717 path.symlink_metadata()
718 .map(|m| m.file_type().is_symlink())
719 .unwrap_or(false)
720 }
721
722 fn remove_path(path: &Path) -> Result<(), AppError> {
723 if Self::is_symlink(path) {
724 #[cfg(unix)]
725 fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
726 #[cfg(windows)]
727 fs::remove_dir(path).map_err(|e| AppError::io(path, e))?;
728 return Ok(());
729 }
730
731 if path.is_dir() {
732 fs::remove_dir_all(path).map_err(|e| AppError::io(path, e))?;
733 } else if path.exists() {
734 fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
735 }
736 Ok(())
737 }
738
739 pub fn sync_to_app_dir(
740 directory: &str,
741 app: &AppType,
742 method: SyncMethod,
743 ) -> Result<(), AppError> {
744 if !Self::app_supports_skills(app) {
745 return Ok(());
746 }
747
748 let ssot_dir = Self::get_ssot_dir()?;
749 let source = ssot_dir.join(directory);
750 if !source.exists() {
751 return Err(AppError::Message(format!(
752 "Skill 不存在于 SSOT: {directory}"
753 )));
754 }
755
756 let app_dir = Self::get_app_skills_dir(app)?;
757 fs::create_dir_all(&app_dir).map_err(|e| AppError::io(&app_dir, e))?;
759
760 let dest = app_dir.join(directory);
761 if dest.exists() || Self::is_symlink(&dest) {
762 Self::remove_path(&dest)?;
763 }
764
765 match method {
766 SyncMethod::Auto => match Self::create_symlink(&source, &dest) {
767 Ok(()) => Ok(()),
768 Err(err) => {
769 log::warn!(
770 "Symlink 创建失败,将回退到文件复制: {} -> {}. 错误: {err}",
771 source.display(),
772 dest.display()
773 );
774 Self::copy_dir_recursive(&source, &dest)
775 }
776 },
777 SyncMethod::Symlink => Self::create_symlink(&source, &dest),
778 SyncMethod::Copy => Self::copy_dir_recursive(&source, &dest),
779 }
780 }
781
782 pub fn remove_from_app(directory: &str, app: &AppType) -> Result<(), AppError> {
783 if !Self::app_supports_skills(app) {
784 return Ok(());
785 }
786
787 let app_dir = Self::get_app_skills_dir(app)?;
788 let path = app_dir.join(directory);
789 if path.exists() || Self::is_symlink(&path) {
790 Self::remove_path(&path)?;
791 }
792 Ok(())
793 }
794
795 pub fn sync_to_app(index: &SkillsIndex, app: &AppType) -> Result<(), AppError> {
796 if !Self::app_supports_skills(app) {
797 return Ok(());
798 }
799
800 for skill in index.skills.values() {
801 if skill.apps.is_enabled_for(app) {
802 Self::sync_to_app_dir(&skill.directory, app, index.sync_method)?;
803 }
804 }
805 Ok(())
806 }
807
808 pub fn sync_all_enabled_best_effort() -> Result<(), AppError> {
810 let mut index = Self::load_index()?;
811 let _ = Self::migrate_ssot_if_pending(&mut index);
812 for app in Self::supported_skill_apps() {
813 if let Err(e) = Self::sync_to_app(&index, &app) {
814 log::warn!("同步 Skill 到 {app:?} 失败: {e}");
815 }
816 }
817 Ok(())
818 }
819
820 pub fn sync_all_enabled(app: Option<&AppType>) -> Result<(), AppError> {
821 let mut index = Self::load_index()?;
822 let _ = Self::migrate_ssot_if_pending(&mut index)?;
823
824 match app {
825 Some(app) => Self::sync_to_app(&index, app)?,
826 None => {
827 for app in Self::supported_skill_apps() {
828 Self::sync_to_app(&index, &app)?;
829 }
830 }
831 }
832
833 Ok(())
834 }
835
836 pub fn list_installed() -> Result<Vec<InstalledSkill>, AppError> {
837 let mut index = Self::load_index()?;
838 let _ = Self::migrate_ssot_if_pending(&mut index)?;
839 let mut skills: Vec<InstalledSkill> = index.skills.values().cloned().collect();
840 skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
841 Ok(skills)
842 }
843
844 pub fn list_repos() -> Result<Vec<SkillRepo>, AppError> {
845 Ok(Self::load_index()?.repos)
846 }
847
848 pub fn get_sync_method() -> Result<SyncMethod, AppError> {
849 Ok(crate::settings::get_skill_sync_method())
850 }
851
852 pub fn set_sync_method(method: SyncMethod) -> Result<(), AppError> {
853 crate::settings::set_skill_sync_method(method)
854 }
855
856 pub fn upsert_repo(repo: SkillRepo) -> Result<(), AppError> {
857 let mut index = Self::load_index()?;
858 if let Some(pos) = index
859 .repos
860 .iter()
861 .position(|r| r.owner == repo.owner && r.name == repo.name)
862 {
863 index.repos[pos] = repo;
864 } else {
865 index.repos.push(repo);
866 }
867 Self::save_index(&index)?;
868 Ok(())
869 }
870
871 pub fn remove_repo(owner: &str, name: &str) -> Result<(), AppError> {
872 let db = Database::init()?;
873 db.delete_skill_repo(owner, name)
874 }
875
876 fn resolve_directory_from_input(index: &SkillsIndex, input: &str) -> Option<String> {
877 let trimmed = input.trim();
878 if trimmed.is_empty() {
879 return None;
880 }
881
882 if index.skills.contains_key(trimmed) {
884 return Some(trimmed.to_string());
885 }
886
887 let trimmed_lower = trimmed.to_lowercase();
889 if let Some((dir, _)) = index
890 .skills
891 .iter()
892 .find(|(dir, _)| dir.to_lowercase() == trimmed_lower)
893 {
894 return Some(dir.clone());
895 }
896
897 if let Some((dir, _)) = index
899 .skills
900 .iter()
901 .find(|(_, s)| s.id.eq_ignore_ascii_case(trimmed))
902 {
903 return Some(dir.clone());
904 }
905
906 None
907 }
908
909 pub fn toggle_app(directory_or_id: &str, app: &AppType, enabled: bool) -> Result<(), AppError> {
910 let mut index = Self::load_index()?;
911 let Some(dir) = Self::resolve_directory_from_input(&index, directory_or_id) else {
912 return Err(AppError::Message(format!(
913 "未找到已安装的 Skill: {directory_or_id}"
914 )));
915 };
916
917 let Some(record) = index.skills.get_mut(&dir) else {
918 return Err(AppError::Message(format!("未找到已安装的 Skill: {dir}")));
919 };
920
921 if !Self::app_supports_skills(app) {
922 return Ok(());
923 }
924
925 record.apps.set_enabled_for(app, enabled);
926
927 if enabled {
928 Self::sync_to_app_dir(&record.directory, app, index.sync_method)?;
929 } else {
930 Self::remove_from_app(&record.directory, app)?;
931 }
932
933 Self::save_index(&index)?;
934 Ok(())
935 }
936
937 pub fn uninstall(directory_or_id: &str) -> Result<(), AppError> {
938 let index = Self::load_index()?;
939 let Some(dir) = Self::resolve_directory_from_input(&index, directory_or_id) else {
940 return Err(AppError::Message(format!(
941 "未找到已安装的 Skill: {directory_or_id}"
942 )));
943 };
944 let record = index
945 .skills
946 .get(&dir)
947 .cloned()
948 .ok_or_else(|| AppError::Message(format!("未找到已安装的 Skill: {dir}")))?;
949
950 for app in [
952 AppType::Claude,
953 AppType::Codex,
954 AppType::Gemini,
955 AppType::OpenCode,
956 AppType::Hermes,
957 ] {
958 if let Err(e) = Self::remove_from_app(&dir, &app) {
959 log::warn!("从 {app:?} 删除 Skill {dir} 失败: {e}");
960 }
961 }
962
963 let ssot_dir = Self::get_ssot_dir()?;
965 let ssot_path = ssot_dir.join(&dir);
966 if ssot_path.exists() {
967 fs::remove_dir_all(&ssot_path).map_err(|e| AppError::io(&ssot_path, e))?;
968 }
969
970 let db = Database::init()?;
971 let _ = db.delete_skill(&record.id)?;
972 Ok(())
973 }
974
975 pub async fn install(&self, spec: &str, app: &AppType) -> Result<InstalledSkill, AppError> {
976 let spec = spec.trim();
977 if spec.is_empty() {
978 return Err(AppError::InvalidInput("Skill 不能为空".to_string()));
979 }
980
981 let mut index = Self::load_index()?;
982 let _ = Self::migrate_ssot_if_pending(&mut index)?;
983
984 let discoverable = self.resolve_install_spec(&index, spec).await?;
986
987 let install_name = Path::new(&discoverable.directory)
989 .file_name()
990 .map(|s| s.to_string_lossy().to_string())
991 .unwrap_or_else(|| discoverable.directory.clone());
992
993 if let Some(existing) = index.skills.get(&install_name) {
995 let same_repo = existing.repo_owner.as_deref()
996 == Some(discoverable.repo_owner.as_str())
997 && existing.repo_name.as_deref() == Some(discoverable.repo_name.as_str());
998 if !same_repo
999 && (existing.repo_owner.is_some()
1000 || existing.repo_name.is_some()
1001 || existing.id.starts_with("local:"))
1002 {
1003 let existing_repo = format!(
1004 "{}/{}",
1005 existing.repo_owner.as_deref().unwrap_or("unknown"),
1006 existing.repo_name.as_deref().unwrap_or("unknown")
1007 );
1008 let new_repo = format!("{}/{}", discoverable.repo_owner, discoverable.repo_name);
1009
1010 return Err(AppError::Message(format_skill_error(
1011 "SKILL_DIRECTORY_CONFLICT",
1012 &[
1013 ("directory", install_name.as_str()),
1014 ("existing_repo", existing_repo.as_str()),
1015 ("new_repo", new_repo.as_str()),
1016 ],
1017 Some("uninstallFirst"),
1018 )));
1019 }
1020
1021 let mut updated = existing.clone();
1023 updated.apps.set_enabled_for(app, true);
1024 index.skills.insert(install_name.clone(), updated.clone());
1025 Self::save_index(&index)?;
1026 Self::sync_to_app_dir(&install_name, app, index.sync_method)?;
1027 return Ok(updated);
1028 }
1029
1030 let ssot_dir = Self::get_ssot_dir()?;
1032 let dest = ssot_dir.join(&install_name);
1033 if !dest.exists() {
1034 let repo = SkillRepo {
1035 owner: discoverable.repo_owner.clone(),
1036 name: discoverable.repo_name.clone(),
1037 branch: discoverable.repo_branch.clone(),
1038 enabled: true,
1039 };
1040
1041 let temp_dir = timeout(
1042 std::time::Duration::from_secs(60),
1043 self.download_repo(&repo),
1044 )
1045 .await
1046 .map_err(|_| {
1047 AppError::Message(format_skill_error(
1048 "DOWNLOAD_TIMEOUT",
1049 &[
1050 ("owner", repo.owner.as_str()),
1051 ("name", repo.name.as_str()),
1052 ("timeout", "60"),
1053 ],
1054 Some("checkNetwork"),
1055 ))
1056 })??;
1057
1058 let source =
1059 Self::find_skill_dir_in_repo(&temp_dir, &install_name)?.ok_or_else(|| {
1060 let _ = fs::remove_dir_all(&temp_dir);
1061 AppError::Message(format_skill_error(
1062 "SKILL_DIR_NOT_FOUND",
1063 &[("directory", install_name.as_str())],
1064 Some("checkRepoUrl"),
1065 ))
1066 })?;
1067
1068 if !source.exists() {
1069 let _ = fs::remove_dir_all(&temp_dir);
1070 let source_path_string = source.display().to_string();
1071 return Err(AppError::Message(format_skill_error(
1072 "SKILL_DIR_NOT_FOUND",
1073 &[("path", source_path_string.as_str())],
1074 Some("checkRepoUrl"),
1075 )));
1076 }
1077
1078 Self::copy_dir_recursive(&source, &dest)?;
1079 let _ = fs::remove_dir_all(&temp_dir);
1080 }
1081
1082 let installed = InstalledSkill {
1083 id: discoverable.key.clone(),
1084 name: discoverable.name.clone(),
1085 description: if discoverable.description.trim().is_empty() {
1086 None
1087 } else {
1088 Some(discoverable.description.clone())
1089 },
1090 directory: install_name.clone(),
1091 readme_url: discoverable.readme_url.clone(),
1092 repo_owner: Some(discoverable.repo_owner.clone()),
1093 repo_name: Some(discoverable.repo_name.clone()),
1094 repo_branch: Some(discoverable.repo_branch.clone()),
1095 apps: SkillApps::only(app),
1096 installed_at: Utc::now().timestamp(),
1097 };
1098
1099 index.skills.insert(install_name.clone(), installed.clone());
1100 Self::save_index(&index)?;
1101 Self::sync_to_app_dir(&install_name, app, index.sync_method)?;
1102
1103 Ok(installed)
1104 }
1105
1106 async fn resolve_install_spec(
1107 &self,
1108 index: &SkillsIndex,
1109 spec: &str,
1110 ) -> Result<DiscoverableSkill, AppError> {
1111 let discoverable = self.discover_available(index.repos.clone()).await?;
1113
1114 if let Some(found) = discoverable.iter().find(|s| s.key == spec) {
1115 return Ok(found.clone());
1116 }
1117
1118 let matches: Vec<DiscoverableSkill> = discoverable
1120 .into_iter()
1121 .filter(|s| s.directory.eq_ignore_ascii_case(spec))
1122 .collect();
1123
1124 match matches.len() {
1125 0 => Err(AppError::Message(format!("未找到可安装的 Skill: {spec}"))),
1126 1 => Ok(matches[0].clone()),
1127 _ => Err(AppError::Message(format!(
1128 "Skill 名称不唯一,请使用完整 key(owner/name:directory): {spec}"
1129 ))),
1130 }
1131 }
1132
1133 pub fn scan_unmanaged() -> Result<Vec<UnmanagedSkill>, AppError> {
1138 let index = Self::load_index()?;
1139 let managed: HashSet<String> = index.skills.keys().cloned().collect();
1140
1141 let mut scan_sources: Vec<(PathBuf, String)> = Vec::new();
1142 for app in Self::supported_skill_apps() {
1143 if let Ok(app_dir) = Self::get_app_skills_dir(&app) {
1144 scan_sources.push((app_dir, app.as_str().to_string()));
1145 }
1146 }
1147 if let Some(agents_dir) = get_agents_skills_dir() {
1148 scan_sources.push((agents_dir, "agents".to_string()));
1149 }
1150 if let Ok(ssot_dir) = Self::get_ssot_dir() {
1151 scan_sources.push((ssot_dir, "cc-switch-tui".to_string()));
1152 }
1153
1154 let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
1155
1156 for (scan_dir, label) in &scan_sources {
1157 let entries = match fs::read_dir(scan_dir) {
1158 Ok(entries) => entries,
1159 Err(_) => continue,
1160 };
1161
1162 for entry in entries {
1163 let entry = match entry {
1164 Ok(entry) => entry,
1165 Err(_) => continue,
1166 };
1167 let path = entry.path();
1168 if !path.is_dir() {
1169 continue;
1170 }
1171
1172 let dir_name = entry.file_name().to_string_lossy().to_string();
1173 if dir_name.starts_with('.') || managed.contains(&dir_name) {
1174 continue;
1175 }
1176
1177 let skill_md = path.join("SKILL.md");
1178 let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
1179
1180 unmanaged
1181 .entry(dir_name.clone())
1182 .and_modify(|skill| {
1183 if !skill.found_in.contains(label) {
1184 skill.found_in.push(label.clone());
1185 }
1186 })
1187 .or_insert(UnmanagedSkill {
1188 directory: dir_name,
1189 name,
1190 description,
1191 found_in: vec![label.clone()],
1192 });
1193 }
1194 }
1195
1196 Ok(unmanaged.into_values().collect())
1197 }
1198
1199 pub fn import_from_apps(directories: Vec<String>) -> Result<Vec<InstalledSkill>, AppError> {
1200 let mut index = Self::load_index()?;
1201 let ssot_dir = Self::get_ssot_dir()?;
1202 let agents_lock = parse_agents_lock();
1203 let mut imported = Vec::new();
1204
1205 merge_repos_from_lock(
1206 &mut index.repos,
1207 &agents_lock,
1208 directories.iter().map(|s| s.as_str()),
1209 );
1210
1211 let mut search_sources: Vec<(PathBuf, String)> = Vec::new();
1212 for app in Self::supported_skill_apps() {
1213 if let Ok(app_dir) = Self::get_app_skills_dir(&app) {
1214 search_sources.push((app_dir, app.as_str().to_string()));
1215 }
1216 }
1217 if let Some(agents_dir) = get_agents_skills_dir() {
1218 search_sources.push((agents_dir, "agents".to_string()));
1219 }
1220 search_sources.push((ssot_dir.clone(), "cc-switch-tui".to_string()));
1221
1222 for dir_name in directories {
1223 let mut source_path: Option<PathBuf> = None;
1224 let mut found_in: Vec<String> = Vec::new();
1225
1226 for (base, label) in &search_sources {
1227 let skill_path = base.join(&dir_name);
1228 if skill_path.exists() {
1229 if source_path.is_none() {
1230 source_path = Some(skill_path);
1231 }
1232 found_in.push(label.clone());
1233 }
1234 }
1235
1236 let Some(source) = source_path else { continue };
1237
1238 let dest = ssot_dir.join(&dir_name);
1239 if !dest.exists() {
1240 Self::copy_dir_recursive(&source, &dest)?;
1241 }
1242
1243 let skill_md = dest.join("SKILL.md");
1244 let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
1245 let apps = SkillApps::from_labels(&found_in);
1246 let (id, repo_owner, repo_name, repo_branch, readme_url) =
1247 build_repo_info_from_lock(&agents_lock, &dir_name);
1248
1249 let skill = InstalledSkill {
1250 id,
1251 name,
1252 description,
1253 directory: dir_name.clone(),
1254 repo_owner,
1255 repo_name,
1256 repo_branch,
1257 readme_url,
1258 apps,
1259 installed_at: Utc::now().timestamp(),
1260 };
1261
1262 index.skills.insert(dir_name.clone(), skill.clone());
1263 imported.push(skill);
1264 }
1265
1266 Self::save_index(&index)?;
1267 Ok(imported)
1268 }
1269
1270 pub async fn discover_available(
1275 &self,
1276 repos: Vec<SkillRepo>,
1277 ) -> Result<Vec<DiscoverableSkill>, AppError> {
1278 let enabled_repos: Vec<SkillRepo> = repos.into_iter().filter(|r| r.enabled).collect();
1279 let tasks = enabled_repos
1280 .iter()
1281 .map(|repo| self.fetch_repo_skills(repo));
1282 let results: Vec<Result<Vec<DiscoverableSkill>, AppError>> = join_all(tasks).await;
1283
1284 let mut skills = Vec::new();
1285 for (repo, result) in enabled_repos.into_iter().zip(results.into_iter()) {
1286 match result {
1287 Ok(repo_skills) => skills.extend(repo_skills),
1288 Err(e) => log::warn!("获取仓库 {}/{} 技能失败: {}", repo.owner, repo.name, e),
1289 }
1290 }
1291
1292 Self::deduplicate_discoverable(&mut skills);
1293 skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
1294 Ok(skills)
1295 }
1296
1297 pub async fn list_skills(&self) -> Result<Vec<Skill>, AppError> {
1298 let mut index = Self::load_index()?;
1299 let _ = Self::migrate_ssot_if_pending(&mut index)?;
1300 let discoverable = self.discover_available(index.repos.clone()).await?;
1301 let installed_dirs: HashSet<String> =
1302 index.skills.keys().map(|s| s.to_lowercase()).collect();
1303
1304 let mut out: Vec<Skill> = discoverable
1305 .into_iter()
1306 .map(|d| {
1307 let installed = installed_dirs.contains(&d.directory.to_lowercase());
1308 Skill {
1309 key: d.key,
1310 name: d.name,
1311 description: d.description,
1312 directory: d.directory,
1313 readme_url: d.readme_url,
1314 installed,
1315 repo_owner: Some(d.repo_owner),
1316 repo_name: Some(d.repo_name),
1317 repo_branch: Some(d.repo_branch),
1318 }
1319 })
1320 .collect();
1321
1322 Self::merge_local_ssot_skills(&index, &mut out)?;
1324
1325 Self::deduplicate_skills(&mut out);
1327 out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
1328 Ok(out)
1329 }
1330}