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