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 expand_env_config_dir(path: PathBuf) -> PathBuf {
277 let lossy = path.to_string_lossy();
278 if lossy == "~" {
279 return crate::config::home_dir().unwrap_or(path);
280 }
281
282 if let Some(rest) = lossy
283 .strip_prefix("~/")
284 .or_else(|| lossy.strip_prefix("~\\"))
285 {
286 if let Some(home) = crate::config::home_dir() {
287 return home.join(rest);
288 }
289 }
290
291 path
292}
293
294fn config_dir_from_env(key: &str) -> Option<PathBuf> {
295 let raw = std::env::var_os(key)?;
296 let path = PathBuf::from(raw);
297 if path.as_os_str().is_empty() || path.to_string_lossy().trim().is_empty() {
298 return None;
299 }
300 Some(expand_env_config_dir(path))
301}
302
303fn get_app_env_config_dir(app: &AppType) -> Option<PathBuf> {
304 match app {
305 AppType::Claude => config_dir_from_env("CLAUDE_CONFIG_DIR"),
306 AppType::Codex => config_dir_from_env("CODEX_HOME"),
307 AppType::Hermes => config_dir_from_env("HERMES_HOME"),
308 AppType::Gemini | AppType::OpenCode | AppType::OpenClaw => None,
309 }
310}
311
312fn get_app_fallback_skills_dir(app: &AppType) -> Result<PathBuf, AppError> {
313 match app {
314 AppType::Claude => {
315 if let Some(custom) = crate::settings::get_claude_override_dir() {
316 return Ok(custom.join("skills"));
317 }
318 }
319 AppType::Codex => {
320 if let Some(custom) = crate::settings::get_codex_override_dir() {
321 return Ok(custom.join("skills"));
322 }
323 }
324 AppType::Gemini => {
325 if let Some(custom) = crate::settings::get_gemini_override_dir() {
326 return Ok(custom.join("skills"));
327 }
328 }
329 AppType::OpenCode => {
330 if let Some(custom) = crate::settings::get_opencode_override_dir() {
331 return Ok(custom.join("skills"));
332 }
333 }
334 AppType::OpenClaw => {
335 if let Some(custom) = crate::settings::get_openclaw_override_dir() {
336 return Ok(custom.join("skills"));
337 }
338 }
339 AppType::Hermes => {
340 if let Some(custom) = crate::settings::get_hermes_override_dir() {
341 return Ok(custom.join("skills"));
342 }
343 }
344 }
345
346 let home = dirs::home_dir().ok_or_else(|| {
347 AppError::Message(format_skill_error(
348 "GET_HOME_DIR_FAILED",
349 &[],
350 Some("checkPermission"),
351 ))
352 })?;
353
354 Ok(match app {
355 AppType::Claude => home.join(".claude").join("skills"),
356 AppType::Codex => home.join(".codex").join("skills"),
357 AppType::Gemini => home.join(".gemini").join("skills"),
358 AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
359 AppType::OpenClaw => home.join(".openclaw").join("skills"),
360 AppType::Hermes => home.join(".hermes").join("skills"),
361 })
362}
363
364fn get_app_skills_dir_for_scan(app: &AppType) -> Result<PathBuf, AppError> {
365 if let Some(env_dir) = get_app_env_config_dir(app) {
366 let env_skills_dir = env_dir.join("skills");
367 if env_skills_dir.is_dir() {
368 return Ok(env_skills_dir);
369 }
370 }
371
372 get_app_fallback_skills_dir(app)
373}
374
375#[derive(Debug, Clone)]
376struct AgentSkillSource {
377 path: PathBuf,
378 label: String,
379 uses_lock: bool,
380 app: Option<AppType>,
381}
382
383fn push_agent_skill_source(
384 sources: &mut Vec<AgentSkillSource>,
385 path: PathBuf,
386 label: String,
387 uses_lock: bool,
388 app: Option<AppType>,
389) {
390 if sources.iter().any(|source| source.path == path) {
391 return;
392 }
393
394 sources.push(AgentSkillSource {
395 path,
396 label,
397 uses_lock,
398 app,
399 });
400}
401
402fn agent_skill_sources() -> Vec<AgentSkillSource> {
403 let mut sources = Vec::new();
404 if let Some(agents_dir) = get_agents_skills_dir() {
405 push_agent_skill_source(&mut sources, agents_dir, "agents".to_string(), true, None);
406 }
407
408 for app in SkillService::supported_skill_apps() {
409 let Ok(app_dir) = get_app_skills_dir_for_scan(&app) else {
410 continue;
411 };
412 if app_dir.exists() {
413 push_agent_skill_source(
414 &mut sources,
415 app_dir,
416 app.as_str().to_string(),
417 false,
418 Some(app),
419 );
420 }
421 }
422
423 sources
424}
425
426fn parse_agents_lock() -> HashMap<String, LockRepoInfo> {
427 let path = match dirs::home_dir() {
428 Some(home) => home.join(".agents").join(".skill-lock.json"),
429 None => return HashMap::new(),
430 };
431
432 let content = match fs::read_to_string(&path) {
433 Ok(content) => content,
434 Err(_) => return HashMap::new(),
435 };
436
437 let lock: AgentsLockFile = match serde_json::from_str(&content) {
438 Ok(lock) => lock,
439 Err(_) => return HashMap::new(),
440 };
441
442 lock.skills
443 .into_iter()
444 .filter_map(|(name, skill)| {
445 let source = skill.source?;
446 if skill.source_type.as_deref() != Some("github") {
447 return None;
448 }
449 let (owner, repo) = source.split_once('/')?;
450 let branch = normalize_optional_branch(skill.branch)
451 .or_else(|| normalize_optional_branch(skill.source_branch))
452 .or_else(|| parse_branch_from_source_url(skill.source_url.as_deref()));
453 Some((
454 name,
455 LockRepoInfo {
456 owner: owner.to_string(),
457 repo: repo.to_string(),
458 skill_path: skill.skill_path,
459 branch,
460 },
461 ))
462 })
463 .collect()
464}
465
466fn build_repo_info_from_lock(
467 lock: &HashMap<String, LockRepoInfo>,
468 dir_name: &str,
469) -> (
470 String,
471 Option<String>,
472 Option<String>,
473 Option<String>,
474 Option<String>,
475) {
476 match lock.get(dir_name) {
477 Some(info) => {
478 let branch = info.branch.clone();
479 let url_branch = branch.clone().unwrap_or_else(|| "HEAD".to_string());
480 let fallback = format!("{dir_name}/SKILL.md");
481 let doc_path = info.skill_path.as_deref().unwrap_or(&fallback);
482 let url = Some(SkillService::build_skill_doc_url(
483 &info.owner,
484 &info.repo,
485 &url_branch,
486 doc_path,
487 ));
488 (
489 format!("{}/{}:{dir_name}", info.owner, info.repo),
490 Some(info.owner.clone()),
491 Some(info.repo.clone()),
492 branch,
493 url,
494 )
495 }
496 None => (format!("local:{dir_name}"), None, None, None, None),
497 }
498}
499
500fn merge_repos_from_lock(
501 repos: &mut Vec<SkillRepo>,
502 lock: &HashMap<String, LockRepoInfo>,
503 directories: impl Iterator<Item = impl AsRef<str>>,
504) {
505 let mut existing: HashSet<(String, String)> = repos
506 .iter()
507 .map(|repo| (repo.owner.clone(), repo.name.clone()))
508 .collect();
509
510 for dir_name in directories {
511 if let Some(info) = lock.get(dir_name.as_ref()) {
512 let key = (info.owner.clone(), info.repo.clone());
513 if existing.insert(key) {
514 repos.push(SkillRepo {
515 owner: info.owner.clone(),
516 name: info.repo.clone(),
517 branch: info.branch.clone().unwrap_or_else(|| "HEAD".to_string()),
518 enabled: true,
519 });
520 }
521 }
522 }
523}
524
525fn parse_bundled_skill_manifest(skills_dir: &Path) -> HashSet<String> {
526 let manifest_path = skills_dir.join(".bundled_manifest");
527 let content = match fs::read_to_string(manifest_path) {
528 Ok(content) => content,
529 Err(_) => return HashSet::new(),
530 };
531
532 content
533 .lines()
534 .filter_map(|line| line.split_once(':').map(|(name, _)| name.trim()))
535 .filter(|name| !name.is_empty())
536 .map(ToOwned::to_owned)
537 .collect()
538}
539
540fn bundled_skill_names_for_source(source: &AgentSkillSource) -> HashSet<String> {
541 if source.app.as_ref() == Some(&AppType::Hermes) {
542 parse_bundled_skill_manifest(&source.path)
543 } else {
544 HashSet::new()
545 }
546}
547
548fn should_offer_agent_skill_dir(path: &Path, dir_name: &str, bundled: &HashSet<String>) -> bool {
549 !dir_name.starts_with('.') && path.join("SKILL.md").is_file() && !bundled.contains(dir_name)
550}
551
552pub struct SkillService {
557 http_client: Client,
558}
559
560impl SkillService {
561 fn app_supports_skills(app: &AppType) -> bool {
562 matches!(
563 app,
564 AppType::Claude
565 | AppType::Codex
566 | AppType::Gemini
567 | AppType::OpenCode
568 | AppType::OpenClaw
569 | AppType::Hermes
570 )
571 }
572
573 fn supported_skill_apps() -> impl Iterator<Item = AppType> {
574 [
575 AppType::Claude,
576 AppType::Codex,
577 AppType::Gemini,
578 AppType::OpenCode,
579 AppType::OpenClaw,
580 AppType::Hermes,
581 ]
582 .into_iter()
583 }
584
585 pub fn new() -> Result<Self, AppError> {
586 let http_client = Client::builder()
587 .user_agent("cc-switch-tui")
588 .timeout(std::time::Duration::from_secs(10))
589 .build()
590 .map_err(|e| {
591 AppError::localized(
592 "skills.http_client_failed",
593 format!("创建 HTTP 客户端失败: {e}"),
594 format!("Failed to create HTTP client: {e}"),
595 )
596 })?;
597
598 Ok(Self { http_client })
599 }
600
601 pub fn get_ssot_dir() -> Result<PathBuf, AppError> {
606 let dir = get_app_config_dir().join("skills");
607 fs::create_dir_all(&dir).map_err(|e| AppError::io(&dir, e))?;
608 Ok(dir)
609 }
610
611 pub fn get_app_skills_dir(app: &AppType) -> Result<PathBuf, AppError> {
612 if let Some(env_dir) = get_app_env_config_dir(app) {
613 return Ok(env_dir.join("skills"));
614 }
615
616 get_app_fallback_skills_dir(app)
617 }
618
619 pub fn load_index() -> Result<SkillsIndex, AppError> {
624 let db = Database::init()?;
625
626 let _ = db.init_default_skill_repos();
628
629 let repos = db.get_skill_repos()?;
630 let installed = db.get_all_installed_skills()?;
631 let skills: HashMap<String, InstalledSkill> = installed
632 .into_values()
633 .map(|skill| (skill.directory.clone(), skill))
634 .collect();
635
636 let sync_method = crate::settings::get_skill_sync_method();
637 let ssot_migration_pending = db
638 .get_setting("skills_ssot_migration_pending")?
639 .is_some_and(|v| v == "true" || v == "1");
640
641 Ok(SkillsIndex {
642 version: SKILLS_INDEX_VERSION,
643 sync_method,
644 repos,
645 skills,
646 ssot_migration_pending,
647 })
648 }
649
650 pub fn save_index(index: &SkillsIndex) -> Result<(), AppError> {
651 let db = Database::init()?;
652
653 crate::settings::set_skill_sync_method(index.sync_method)?;
654
655 for repo in &index.repos {
656 db.save_skill_repo(repo)?;
657 }
658
659 for skill in index.skills.values() {
660 db.save_skill(skill)?;
661 }
662
663 Ok(())
664 }
665
666 fn reconcile_managed_app_enablement_from_live_dirs(
667 index: &mut SkillsIndex,
668 ) -> Result<bool, AppError> {
669 let mut changed = false;
670
671 for app in Self::supported_skill_apps() {
672 let app_dir = match get_app_skills_dir_for_scan(&app) {
673 Ok(dir) => dir,
674 Err(_) => continue,
675 };
676 if !app_dir.is_dir() {
677 continue;
678 }
679
680 for skill in index.skills.values_mut() {
681 if skill.apps.is_enabled_for(&app) {
682 continue;
683 }
684
685 let live_skill_dir = app_dir.join(&skill.directory);
686 if live_skill_dir.is_dir() && live_skill_dir.join("SKILL.md").is_file() {
687 skill.apps.set_enabled_for(&app, true);
688 changed = true;
689 }
690 }
691 }
692
693 if changed {
694 Self::save_index(index)?;
695 }
696
697 Ok(changed)
698 }
699
700 pub fn migrate_ssot_if_pending(index: &mut SkillsIndex) -> Result<usize, AppError> {
705 if !index.ssot_migration_pending {
706 return Ok(0);
707 }
708
709 let db = Database::init()?;
710 let ssot_dir = Self::get_ssot_dir()?;
711 let mut created = 0usize;
712
713 if !index.skills.is_empty() {
719 for (directory, record) in index.skills.iter_mut() {
720 let dest = ssot_dir.join(directory);
721 if dest.exists() {
722 continue;
723 }
724
725 let mut candidates: Vec<AppType> = Self::supported_skill_apps()
727 .into_iter()
728 .filter(|app| record.apps.is_enabled_for(app))
729 .collect();
730 if candidates.is_empty() {
731 candidates = Self::supported_skill_apps().collect();
732 }
733
734 let mut source: Option<PathBuf> = None;
735 for app in candidates {
736 let app_dir = match get_app_skills_dir_for_scan(&app) {
737 Ok(d) => d,
738 Err(_) => continue,
739 };
740 let skill_path = app_dir.join(directory);
741 if skill_path.exists() {
742 source = Some(skill_path);
743 break;
744 }
745 }
746
747 match source {
748 Some(source) => {
749 Self::copy_dir_recursive(&source, &dest)?;
750 created += 1;
751
752 let skill_md = dest.join("SKILL.md");
754 if skill_md.exists() {
755 if let Ok(meta) = Self::parse_skill_metadata_static(&skill_md) {
756 if record.name.trim().is_empty()
757 || record.name.eq_ignore_ascii_case(&record.directory)
758 {
759 record.name =
760 meta.name.unwrap_or_else(|| record.directory.clone());
761 }
762 if record.description.is_none() {
763 record.description = meta.description;
764 }
765 }
766 }
767 }
768 None => {
769 log::warn!(
770 "SSOT 迁移: 未找到技能目录来源(directory={directory}),已跳过复制"
771 );
772 }
773 }
774 }
775
776 index.ssot_migration_pending = false;
777 let _ = db.set_setting("skills_ssot_migration_pending", "false");
778 Self::save_index(index)?;
779 return Ok(created);
780 }
781
782 let mut discovered: HashMap<String, SkillApps> = HashMap::new();
783
784 for app in Self::supported_skill_apps() {
785 let app_dir = match get_app_skills_dir_for_scan(&app) {
786 Ok(d) => d,
787 Err(_) => continue,
788 };
789 if !app_dir.exists() {
790 continue;
791 }
792
793 for entry in fs::read_dir(&app_dir).map_err(|e| AppError::io(&app_dir, e))? {
794 let entry = entry.map_err(|e| AppError::io(&app_dir, e))?;
795 let path = entry.path();
796 if !path.is_dir() {
797 continue;
798 }
799
800 let dir_name = entry.file_name().to_string_lossy().to_string();
801 if dir_name.starts_with('.') {
802 continue;
803 }
804
805 let ssot_path = ssot_dir.join(&dir_name);
807 if !ssot_path.exists() {
808 Self::copy_dir_recursive(&path, &ssot_path)?;
809 }
810
811 discovered
812 .entry(dir_name)
813 .or_default()
814 .set_enabled_for(&app, true);
815 }
816 }
817
818 for (directory, apps) in discovered {
820 let ssot_path = ssot_dir.join(&directory);
821 let skill_md = ssot_path.join("SKILL.md");
822 let (name, description) = if skill_md.exists() {
823 match Self::parse_skill_metadata_static(&skill_md) {
824 Ok(meta) => (
825 meta.name.unwrap_or_else(|| directory.clone()),
826 meta.description,
827 ),
828 Err(_) => (directory.clone(), None),
829 }
830 } else {
831 (directory.clone(), None)
832 };
833
834 match index.skills.get_mut(&directory) {
835 Some(existing) => {
836 existing.apps.merge_enabled(&apps);
837 if existing.name.trim().is_empty() {
838 existing.name = name;
839 }
840 if existing.description.is_none() {
841 existing.description = description;
842 }
843 }
844 None => {
845 index.skills.insert(
846 directory.clone(),
847 InstalledSkill {
848 id: format!("local:{directory}"),
849 name,
850 description,
851 directory: directory.clone(),
852 readme_url: None,
853 repo_owner: None,
854 repo_name: None,
855 repo_branch: None,
856 apps,
857 installed_at: Utc::now().timestamp(),
858 },
859 );
860 created += 1;
861 }
862 }
863 }
864
865 index.ssot_migration_pending = false;
866 let _ = db.set_setting("skills_ssot_migration_pending", "false");
867 Self::save_index(index)?;
868 Ok(created)
869 }
870
871 #[cfg(unix)]
876 fn create_symlink(src: &Path, dest: &Path) -> Result<(), AppError> {
877 std::os::unix::fs::symlink(src, dest).map_err(|e| AppError::IoContext {
878 context: format!("创建符号链接失败 ({} -> {})", src.display(), dest.display()),
879 source: e,
880 })
881 }
882
883 #[cfg(windows)]
884 fn create_symlink(src: &Path, dest: &Path) -> Result<(), AppError> {
885 std::os::windows::fs::symlink_dir(src, dest).map_err(|e| AppError::IoContext {
886 context: format!("创建符号链接失败 ({} -> {})", src.display(), dest.display()),
887 source: e,
888 })
889 }
890
891 fn is_symlink(path: &Path) -> bool {
892 path.symlink_metadata()
893 .map(|m| m.file_type().is_symlink())
894 .unwrap_or(false)
895 }
896
897 fn remove_path(path: &Path) -> Result<(), AppError> {
898 if Self::is_symlink(path) {
899 #[cfg(unix)]
900 fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
901 #[cfg(windows)]
902 fs::remove_dir(path).map_err(|e| AppError::io(path, e))?;
903 return Ok(());
904 }
905
906 if path.is_dir() {
907 fs::remove_dir_all(path).map_err(|e| AppError::io(path, e))?;
908 } else if path.exists() {
909 fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
910 }
911 Ok(())
912 }
913
914 pub fn sync_to_app_dir(
915 directory: &str,
916 app: &AppType,
917 method: SyncMethod,
918 ) -> Result<(), AppError> {
919 if !Self::app_supports_skills(app) {
920 return Ok(());
921 }
922
923 let ssot_dir = Self::get_ssot_dir()?;
924 let source = ssot_dir.join(directory);
925 if !source.exists() {
926 return Err(AppError::Message(format!(
927 "Skill 不存在于 SSOT: {directory}"
928 )));
929 }
930
931 let app_dir = Self::get_app_skills_dir(app)?;
932 fs::create_dir_all(&app_dir).map_err(|e| AppError::io(&app_dir, e))?;
934
935 let dest = app_dir.join(directory);
936 if dest.exists() || Self::is_symlink(&dest) {
937 Self::remove_path(&dest)?;
938 }
939
940 match method {
941 SyncMethod::Auto => match Self::create_symlink(&source, &dest) {
942 Ok(()) => Ok(()),
943 Err(err) => {
944 log::warn!(
945 "Symlink 创建失败,将回退到文件复制: {} -> {}. 错误: {err}",
946 source.display(),
947 dest.display()
948 );
949 Self::copy_dir_recursive(&source, &dest)
950 }
951 },
952 SyncMethod::Symlink => Self::create_symlink(&source, &dest),
953 SyncMethod::Copy => Self::copy_dir_recursive(&source, &dest),
954 }
955 }
956
957 pub fn remove_from_app(directory: &str, app: &AppType) -> Result<(), AppError> {
958 if !Self::app_supports_skills(app) {
959 return Ok(());
960 }
961
962 let app_dir = Self::get_app_skills_dir(app)?;
963 let path = app_dir.join(directory);
964 if path.exists() || Self::is_symlink(&path) {
965 Self::remove_path(&path)?;
966 }
967 Ok(())
968 }
969
970 pub fn sync_to_app(index: &SkillsIndex, app: &AppType) -> Result<(), AppError> {
971 if !Self::app_supports_skills(app) {
972 return Ok(());
973 }
974
975 for skill in index.skills.values() {
976 if skill.apps.is_enabled_for(app) {
977 Self::sync_to_app_dir(&skill.directory, app, index.sync_method)?;
978 }
979 }
980 Ok(())
981 }
982
983 pub fn sync_all_enabled_best_effort() -> Result<(), AppError> {
985 let mut index = Self::load_index()?;
986 let _ = Self::migrate_ssot_if_pending(&mut index);
987 for app in Self::supported_skill_apps() {
988 if let Err(e) = Self::sync_to_app(&index, &app) {
989 log::warn!("同步 Skill 到 {app:?} 失败: {e}");
990 }
991 }
992 Ok(())
993 }
994
995 pub fn sync_all_enabled(app: Option<&AppType>) -> Result<(), AppError> {
996 let mut index = Self::load_index()?;
997 let _ = Self::migrate_ssot_if_pending(&mut index)?;
998
999 match app {
1000 Some(app) => Self::sync_to_app(&index, app)?,
1001 None => {
1002 for app in Self::supported_skill_apps() {
1003 Self::sync_to_app(&index, &app)?;
1004 }
1005 }
1006 }
1007
1008 Ok(())
1009 }
1010
1011 pub fn list_installed() -> Result<Vec<InstalledSkill>, AppError> {
1012 let mut index = Self::load_index()?;
1013 let _ = Self::migrate_ssot_if_pending(&mut index)?;
1014 let _ = Self::reconcile_managed_app_enablement_from_live_dirs(&mut index)?;
1015 let mut skills: Vec<InstalledSkill> = index.skills.values().cloned().collect();
1016 skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
1017 Ok(skills)
1018 }
1019
1020 pub fn list_repos() -> Result<Vec<SkillRepo>, AppError> {
1021 Ok(Self::load_index()?.repos)
1022 }
1023
1024 pub fn get_sync_method() -> Result<SyncMethod, AppError> {
1025 Ok(crate::settings::get_skill_sync_method())
1026 }
1027
1028 pub fn set_sync_method(method: SyncMethod) -> Result<(), AppError> {
1029 crate::settings::set_skill_sync_method(method)
1030 }
1031
1032 pub fn upsert_repo(repo: SkillRepo) -> Result<(), AppError> {
1033 let mut index = Self::load_index()?;
1034 if let Some(pos) = index
1035 .repos
1036 .iter()
1037 .position(|r| r.owner == repo.owner && r.name == repo.name)
1038 {
1039 index.repos[pos] = repo;
1040 } else {
1041 index.repos.push(repo);
1042 }
1043 Self::save_index(&index)?;
1044 Ok(())
1045 }
1046
1047 pub fn remove_repo(owner: &str, name: &str) -> Result<(), AppError> {
1048 let db = Database::init()?;
1049 db.delete_skill_repo(owner, name)
1050 }
1051
1052 fn resolve_directory_from_input(index: &SkillsIndex, input: &str) -> Option<String> {
1053 let trimmed = input.trim();
1054 if trimmed.is_empty() {
1055 return None;
1056 }
1057
1058 if index.skills.contains_key(trimmed) {
1060 return Some(trimmed.to_string());
1061 }
1062
1063 let trimmed_lower = trimmed.to_lowercase();
1065 if let Some((dir, _)) = index
1066 .skills
1067 .iter()
1068 .find(|(dir, _)| dir.to_lowercase() == trimmed_lower)
1069 {
1070 return Some(dir.clone());
1071 }
1072
1073 if let Some((dir, _)) = index
1075 .skills
1076 .iter()
1077 .find(|(_, s)| s.id.eq_ignore_ascii_case(trimmed))
1078 {
1079 return Some(dir.clone());
1080 }
1081
1082 None
1083 }
1084
1085 pub fn toggle_app(directory_or_id: &str, app: &AppType, enabled: bool) -> Result<(), AppError> {
1086 let mut index = Self::load_index()?;
1087 let Some(dir) = Self::resolve_directory_from_input(&index, directory_or_id) else {
1088 return Err(AppError::Message(format!(
1089 "未找到已安装的 Skill: {directory_or_id}"
1090 )));
1091 };
1092
1093 let Some(record) = index.skills.get_mut(&dir) else {
1094 return Err(AppError::Message(format!("未找到已安装的 Skill: {dir}")));
1095 };
1096
1097 if !Self::app_supports_skills(app) {
1098 return Ok(());
1099 }
1100
1101 record.apps.set_enabled_for(app, enabled);
1102
1103 if enabled {
1104 Self::sync_to_app_dir(&record.directory, app, index.sync_method)?;
1105 } else {
1106 Self::remove_from_app(&record.directory, app)?;
1107 }
1108
1109 Self::save_index(&index)?;
1110 Ok(())
1111 }
1112
1113 pub fn uninstall(directory_or_id: &str) -> Result<(), AppError> {
1114 let index = Self::load_index()?;
1115 let Some(dir) = Self::resolve_directory_from_input(&index, directory_or_id) else {
1116 return Err(AppError::Message(format!(
1117 "未找到已安装的 Skill: {directory_or_id}"
1118 )));
1119 };
1120 let record = index
1121 .skills
1122 .get(&dir)
1123 .cloned()
1124 .ok_or_else(|| AppError::Message(format!("未找到已安装的 Skill: {dir}")))?;
1125
1126 for app in Self::supported_skill_apps() {
1128 if let Err(e) = Self::remove_from_app(&dir, &app) {
1129 log::warn!("从 {app:?} 删除 Skill {dir} 失败: {e}");
1130 }
1131 }
1132
1133 let ssot_dir = Self::get_ssot_dir()?;
1135 let ssot_path = ssot_dir.join(&dir);
1136 if ssot_path.exists() {
1137 fs::remove_dir_all(&ssot_path).map_err(|e| AppError::io(&ssot_path, e))?;
1138 }
1139
1140 let db = Database::init()?;
1141 let _ = db.delete_skill(&record.id)?;
1142 Ok(())
1143 }
1144
1145 pub async fn install(&self, spec: &str, app: &AppType) -> Result<InstalledSkill, AppError> {
1146 let spec = spec.trim();
1147 if spec.is_empty() {
1148 return Err(AppError::InvalidInput("Skill 不能为空".to_string()));
1149 }
1150
1151 let mut index = Self::load_index()?;
1152 let _ = Self::migrate_ssot_if_pending(&mut index)?;
1153
1154 let discoverable = self.resolve_install_spec(&index, spec).await?;
1156
1157 let install_name = Path::new(&discoverable.directory)
1159 .file_name()
1160 .map(|s| s.to_string_lossy().to_string())
1161 .unwrap_or_else(|| discoverable.directory.clone());
1162
1163 if let Some(existing) = index.skills.get(&install_name) {
1165 let same_repo = existing.repo_owner.as_deref()
1166 == Some(discoverable.repo_owner.as_str())
1167 && existing.repo_name.as_deref() == Some(discoverable.repo_name.as_str());
1168 if !same_repo
1169 && (existing.repo_owner.is_some()
1170 || existing.repo_name.is_some()
1171 || existing.id.starts_with("local:"))
1172 {
1173 let existing_repo = format!(
1174 "{}/{}",
1175 existing.repo_owner.as_deref().unwrap_or("unknown"),
1176 existing.repo_name.as_deref().unwrap_or("unknown")
1177 );
1178 let new_repo = format!("{}/{}", discoverable.repo_owner, discoverable.repo_name);
1179
1180 return Err(AppError::Message(format_skill_error(
1181 "SKILL_DIRECTORY_CONFLICT",
1182 &[
1183 ("directory", install_name.as_str()),
1184 ("existing_repo", existing_repo.as_str()),
1185 ("new_repo", new_repo.as_str()),
1186 ],
1187 Some("uninstallFirst"),
1188 )));
1189 }
1190
1191 let mut updated = existing.clone();
1193 updated.apps.set_enabled_for(app, true);
1194 index.skills.insert(install_name.clone(), updated.clone());
1195 Self::save_index(&index)?;
1196 Self::sync_to_app_dir(&install_name, app, index.sync_method)?;
1197 return Ok(updated);
1198 }
1199
1200 let ssot_dir = Self::get_ssot_dir()?;
1202 let dest = ssot_dir.join(&install_name);
1203 if !dest.exists() {
1204 let repo = SkillRepo {
1205 owner: discoverable.repo_owner.clone(),
1206 name: discoverable.repo_name.clone(),
1207 branch: discoverable.repo_branch.clone(),
1208 enabled: true,
1209 };
1210
1211 let temp_dir = timeout(
1212 std::time::Duration::from_secs(60),
1213 self.download_repo(&repo),
1214 )
1215 .await
1216 .map_err(|_| {
1217 AppError::Message(format_skill_error(
1218 "DOWNLOAD_TIMEOUT",
1219 &[
1220 ("owner", repo.owner.as_str()),
1221 ("name", repo.name.as_str()),
1222 ("timeout", "60"),
1223 ],
1224 Some("checkNetwork"),
1225 ))
1226 })??;
1227
1228 let source =
1229 Self::find_skill_dir_in_repo(&temp_dir, &install_name)?.ok_or_else(|| {
1230 let _ = fs::remove_dir_all(&temp_dir);
1231 AppError::Message(format_skill_error(
1232 "SKILL_DIR_NOT_FOUND",
1233 &[("directory", install_name.as_str())],
1234 Some("checkRepoUrl"),
1235 ))
1236 })?;
1237
1238 if !source.exists() {
1239 let _ = fs::remove_dir_all(&temp_dir);
1240 let source_path_string = source.display().to_string();
1241 return Err(AppError::Message(format_skill_error(
1242 "SKILL_DIR_NOT_FOUND",
1243 &[("path", source_path_string.as_str())],
1244 Some("checkRepoUrl"),
1245 )));
1246 }
1247
1248 Self::copy_dir_recursive(&source, &dest)?;
1249 let _ = fs::remove_dir_all(&temp_dir);
1250 }
1251
1252 let installed = InstalledSkill {
1253 id: discoverable.key.clone(),
1254 name: discoverable.name.clone(),
1255 description: if discoverable.description.trim().is_empty() {
1256 None
1257 } else {
1258 Some(discoverable.description.clone())
1259 },
1260 directory: install_name.clone(),
1261 readme_url: discoverable.readme_url.clone(),
1262 repo_owner: Some(discoverable.repo_owner.clone()),
1263 repo_name: Some(discoverable.repo_name.clone()),
1264 repo_branch: Some(discoverable.repo_branch.clone()),
1265 apps: SkillApps::only(app),
1266 installed_at: Utc::now().timestamp(),
1267 };
1268
1269 index.skills.insert(install_name.clone(), installed.clone());
1270 Self::save_index(&index)?;
1271 Self::sync_to_app_dir(&install_name, app, index.sync_method)?;
1272
1273 Ok(installed)
1274 }
1275
1276 async fn resolve_install_spec(
1277 &self,
1278 index: &SkillsIndex,
1279 spec: &str,
1280 ) -> Result<DiscoverableSkill, AppError> {
1281 let discoverable = self.discover_available(index.repos.clone()).await?;
1283
1284 if let Some(found) = discoverable.iter().find(|s| s.key == spec) {
1285 return Ok(found.clone());
1286 }
1287
1288 let matches: Vec<DiscoverableSkill> = discoverable
1290 .into_iter()
1291 .filter(|s| s.directory.eq_ignore_ascii_case(spec))
1292 .collect();
1293
1294 match matches.len() {
1295 0 => Err(AppError::Message(format!("未找到可安装的 Skill: {spec}"))),
1296 1 => Ok(matches[0].clone()),
1297 _ => Err(AppError::Message(format!(
1298 "Skill 名称不唯一,请使用完整 key(owner/name:directory): {spec}"
1299 ))),
1300 }
1301 }
1302
1303 pub fn scan_unmanaged() -> Result<Vec<UnmanagedSkill>, AppError> {
1308 let index = Self::load_index()?;
1309 let managed: HashSet<String> = index.skills.keys().cloned().collect();
1310
1311 let mut scan_sources: Vec<(PathBuf, String)> = Vec::new();
1312 for app in Self::supported_skill_apps() {
1313 if let Ok(app_dir) = get_app_skills_dir_for_scan(&app) {
1314 scan_sources.push((app_dir, app.as_str().to_string()));
1315 }
1316 }
1317 if let Some(agents_dir) = get_agents_skills_dir() {
1318 scan_sources.push((agents_dir, "agents".to_string()));
1319 }
1320 if let Ok(ssot_dir) = Self::get_ssot_dir() {
1321 scan_sources.push((ssot_dir, "cc-switch-tui".to_string()));
1322 }
1323
1324 let mut unmanaged: HashMap<String, UnmanagedSkill> = HashMap::new();
1325
1326 for (scan_dir, label) in &scan_sources {
1327 let entries = match fs::read_dir(scan_dir) {
1328 Ok(entries) => entries,
1329 Err(_) => continue,
1330 };
1331
1332 for entry in entries {
1333 let entry = match entry {
1334 Ok(entry) => entry,
1335 Err(_) => continue,
1336 };
1337 let path = entry.path();
1338 if !path.is_dir() {
1339 continue;
1340 }
1341
1342 let dir_name = entry.file_name().to_string_lossy().to_string();
1343 if dir_name.starts_with('.') || managed.contains(&dir_name) {
1344 continue;
1345 }
1346
1347 let skill_md = path.join("SKILL.md");
1348 let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
1349
1350 unmanaged
1351 .entry(dir_name.clone())
1352 .and_modify(|skill| {
1353 if !skill.found_in.contains(label) {
1354 skill.found_in.push(label.clone());
1355 }
1356 })
1357 .or_insert(UnmanagedSkill {
1358 directory: dir_name,
1359 name,
1360 description,
1361 found_in: vec![label.clone()],
1362 });
1363 }
1364 }
1365
1366 Ok(unmanaged.into_values().collect())
1367 }
1368
1369 pub fn scan_agent_installed() -> Result<Vec<UnmanagedSkill>, AppError> {
1370 let index = Self::load_index()?;
1371 let sources: Vec<_> = agent_skill_sources()
1372 .into_iter()
1373 .map(|source| {
1374 let bundled = bundled_skill_names_for_source(&source);
1375 (source, bundled)
1376 })
1377 .collect();
1378 if sources.is_empty() {
1379 return Ok(Vec::new());
1380 }
1381
1382 let mut agent_skills: HashMap<String, UnmanagedSkill> = HashMap::new();
1383
1384 for (source, bundled) in sources {
1385 let entries = match fs::read_dir(&source.path) {
1386 Ok(entries) => entries,
1387 Err(_) => continue,
1388 };
1389
1390 for entry in entries {
1391 let entry = match entry {
1392 Ok(entry) => entry,
1393 Err(_) => continue,
1394 };
1395 let path = entry.path();
1396 if !path.is_dir() {
1397 continue;
1398 }
1399
1400 let dir_name = entry.file_name().to_string_lossy().to_string();
1401 if !should_offer_agent_skill_dir(&path, &dir_name, &bundled) {
1402 continue;
1403 }
1404
1405 if index.skills.contains_key(&dir_name) {
1406 continue;
1407 }
1408
1409 let skill_md = path.join("SKILL.md");
1410 let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
1411 agent_skills
1412 .entry(dir_name.clone())
1413 .and_modify(|skill| {
1414 if !skill.found_in.contains(&source.label) {
1415 skill.found_in.push(source.label.clone());
1416 }
1417 })
1418 .or_insert(UnmanagedSkill {
1419 directory: dir_name,
1420 name,
1421 description,
1422 found_in: vec![source.label.clone()],
1423 });
1424 }
1425 }
1426
1427 let mut agent_skills: Vec<_> = agent_skills.into_values().collect();
1428 agent_skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
1429 Ok(agent_skills)
1430 }
1431
1432 pub fn import_from_apps(directories: Vec<String>) -> Result<Vec<InstalledSkill>, AppError> {
1433 let mut index = Self::load_index()?;
1434 let ssot_dir = Self::get_ssot_dir()?;
1435 let agents_lock = parse_agents_lock();
1436 let mut imported = Vec::new();
1437
1438 merge_repos_from_lock(
1439 &mut index.repos,
1440 &agents_lock,
1441 directories.iter().map(|s| s.as_str()),
1442 );
1443
1444 let mut search_sources: Vec<(PathBuf, String)> = Vec::new();
1445 for app in Self::supported_skill_apps() {
1446 if let Ok(app_dir) = get_app_skills_dir_for_scan(&app) {
1447 search_sources.push((app_dir, app.as_str().to_string()));
1448 }
1449 }
1450 if let Some(agents_dir) = get_agents_skills_dir() {
1451 search_sources.push((agents_dir, "agents".to_string()));
1452 }
1453 search_sources.push((ssot_dir.clone(), "cc-switch-tui".to_string()));
1454
1455 for dir_name in directories {
1456 let mut source_path: Option<PathBuf> = None;
1457 let mut found_in: Vec<String> = Vec::new();
1458
1459 for (base, label) in &search_sources {
1460 let skill_path = base.join(&dir_name);
1461 if skill_path.exists() {
1462 if source_path.is_none() {
1463 source_path = Some(skill_path);
1464 }
1465 found_in.push(label.clone());
1466 }
1467 }
1468
1469 let Some(source) = source_path else { continue };
1470
1471 let dest = ssot_dir.join(&dir_name);
1472 if !dest.exists() {
1473 Self::copy_dir_recursive(&source, &dest)?;
1474 }
1475
1476 let skill_md = dest.join("SKILL.md");
1477 let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
1478 let apps = SkillApps::from_labels(&found_in);
1479 let (id, repo_owner, repo_name, repo_branch, readme_url) =
1480 build_repo_info_from_lock(&agents_lock, &dir_name);
1481
1482 let skill = InstalledSkill {
1483 id,
1484 name,
1485 description,
1486 directory: dir_name.clone(),
1487 repo_owner,
1488 repo_name,
1489 repo_branch,
1490 readme_url,
1491 apps,
1492 installed_at: Utc::now().timestamp(),
1493 };
1494
1495 index.skills.insert(dir_name.clone(), skill.clone());
1496 imported.push(skill);
1497 }
1498
1499 Self::save_index(&index)?;
1500 Ok(imported)
1501 }
1502
1503 pub fn import_from_agent(directories: Vec<String>) -> Result<Vec<InstalledSkill>, AppError> {
1504 let mut index = Self::load_index()?;
1505 let ssot_dir = Self::get_ssot_dir()?;
1506 let agents_lock = parse_agents_lock();
1507 let sources: Vec<_> = agent_skill_sources()
1508 .into_iter()
1509 .map(|source| {
1510 let bundled = bundled_skill_names_for_source(&source);
1511 (source, bundled)
1512 })
1513 .collect();
1514 if sources.is_empty() {
1515 return Ok(Vec::new());
1516 }
1517 let mut imported = Vec::new();
1518 let mut seen_directories = HashSet::new();
1519 let directories: Vec<String> = directories
1520 .into_iter()
1521 .filter(|dir_name| {
1522 seen_directories.insert(dir_name.clone()) && !index.skills.contains_key(dir_name)
1523 })
1524 .collect();
1525 if directories.is_empty() {
1526 return Ok(imported);
1527 }
1528
1529 merge_repos_from_lock(
1530 &mut index.repos,
1531 &agents_lock,
1532 directories.iter().map(|s| s.as_str()),
1533 );
1534
1535 for dir_name in directories {
1536 let mut source_path: Option<PathBuf> = None;
1537 let mut source_uses_lock = false;
1538 let mut apps = SkillApps::default();
1539
1540 for (source, bundled) in &sources {
1541 let path = source.path.join(&dir_name);
1542 if !path.is_dir() || !should_offer_agent_skill_dir(&path, &dir_name, &bundled) {
1543 continue;
1544 }
1545
1546 if source_path.is_none() {
1547 source_path = Some(path);
1548 source_uses_lock = source.uses_lock;
1549 }
1550 if let Some(app) = &source.app {
1551 apps.set_enabled_for(app, true);
1552 }
1553 }
1554
1555 let Some(source) = source_path else {
1556 continue;
1557 };
1558
1559 let dest = ssot_dir.join(&dir_name);
1560 if !dest.exists() {
1561 Self::copy_dir_recursive(&source, &dest)?;
1562 }
1563
1564 let skill_md = dest.join("SKILL.md");
1565 let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
1566 let (id, repo_owner, repo_name, repo_branch, readme_url) = if source_uses_lock {
1567 build_repo_info_from_lock(&agents_lock, &dir_name)
1568 } else {
1569 (format!("local:{dir_name}"), None, None, None, None)
1570 };
1571
1572 let skill = InstalledSkill {
1573 id,
1574 name,
1575 description,
1576 directory: dir_name.clone(),
1577 repo_owner,
1578 repo_name,
1579 repo_branch,
1580 readme_url,
1581 apps,
1582 installed_at: Utc::now().timestamp(),
1583 };
1584
1585 index.skills.insert(dir_name, skill.clone());
1586 imported.push(skill);
1587 }
1588
1589 Self::save_index(&index)?;
1590 Ok(imported)
1591 }
1592
1593 pub async fn discover_available(
1598 &self,
1599 repos: Vec<SkillRepo>,
1600 ) -> Result<Vec<DiscoverableSkill>, AppError> {
1601 let enabled_repos: Vec<SkillRepo> = repos.into_iter().filter(|r| r.enabled).collect();
1602 let tasks = enabled_repos
1603 .iter()
1604 .map(|repo| self.fetch_repo_skills(repo));
1605 let results: Vec<Result<Vec<DiscoverableSkill>, AppError>> = join_all(tasks).await;
1606
1607 let mut skills = Vec::new();
1608 for (repo, result) in enabled_repos.into_iter().zip(results.into_iter()) {
1609 match result {
1610 Ok(repo_skills) => skills.extend(repo_skills),
1611 Err(e) => log::warn!("获取仓库 {}/{} 技能失败: {}", repo.owner, repo.name, e),
1612 }
1613 }
1614
1615 Self::deduplicate_discoverable(&mut skills);
1616 skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
1617 Ok(skills)
1618 }
1619
1620 pub async fn list_skills(&self) -> Result<Vec<Skill>, AppError> {
1621 let mut index = Self::load_index()?;
1622 let _ = Self::migrate_ssot_if_pending(&mut index)?;
1623 let discoverable = self.discover_available(index.repos.clone()).await?;
1624 let installed_dirs: HashSet<String> =
1625 index.skills.keys().map(|s| s.to_lowercase()).collect();
1626
1627 let mut out: Vec<Skill> = discoverable
1628 .into_iter()
1629 .map(|d| {
1630 let installed = installed_dirs.contains(&d.directory.to_lowercase());
1631 Skill {
1632 key: d.key,
1633 name: d.name,
1634 description: d.description,
1635 directory: d.directory,
1636 readme_url: d.readme_url,
1637 installed,
1638 repo_owner: Some(d.repo_owner),
1639 repo_name: Some(d.repo_name),
1640 repo_branch: Some(d.repo_branch),
1641 }
1642 })
1643 .collect();
1644
1645 Self::merge_local_ssot_skills(&index, &mut out)?;
1647
1648 Self::deduplicate_skills(&mut out);
1650 out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
1651 Ok(out)
1652 }
1653}