Skip to main content

cc_switch_lib/services/
skill.rs

1//! Skills service layer
2//!
3//! v3.10.0+ 统一管理架构(与上游一致):
4//! - SSOT(单一事实源):`~/.cc-switch-tui/skills/`
5//! - 数据库存储安装记录、启用状态与仓库列表(`~/.cc-switch-tui/cc-switch.db`)
6
7mod 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// ============================================================================
31// Legacy (v2) store structures - kept for backward compatibility
32// ============================================================================
33
34/// Skill repository configuration (legacy, kept for backward compatibility).
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SkillRepo {
37    /// GitHub 用户/组织名
38    pub owner: String,
39    /// 仓库名称
40    pub name: String,
41    /// 分支 (默认 "main")
42    pub branch: String,
43    /// 是否启用
44    pub enabled: bool,
45}
46
47/// Legacy install state: directory -> installed timestamp (Claude-only era).
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SkillState {
50    /// 是否已安装
51    pub installed: bool,
52    /// 安装时间
53    #[serde(rename = "installedAt")]
54    pub installed_at: DateTime<Utc>,
55}
56
57/// Legacy persistent store (was embedded in config.json in older CLI versions).
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct SkillStore {
60    /// directory -> 安装状态
61    pub skills: HashMap<String, SkillState>,
62    /// 仓库列表
63    pub repos: Vec<SkillRepo>,
64}
65
66impl Default for SkillStore {
67    fn default() -> Self {
68        SkillStore {
69            skills: HashMap::new(),
70            // Keep aligned with upstream defaults where possible.
71            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// ============================================================================
102// New (Phase 3) SSOT-based model persisted to ~/.cc-switch-tui/skills.json (no DB)
103// ============================================================================
104
105/// Skill sync method (upstream-aligned).
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
107#[serde(rename_all = "lowercase")]
108pub enum SyncMethod {
109    /// Auto choose: prefer symlink, fallback to copy.
110    #[default]
111    Auto,
112    /// Always use symlink.
113    Symlink,
114    /// Always use directory copy.
115    Copy,
116}
117
118/// skills.json (SSOT index; no DB).
119#[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    /// directory -> record
129    #[serde(default)]
130    pub skills: HashMap<String, InstalledSkill>,
131    /// One-time SSOT migration flag (scan app dirs -> copy into SSOT -> build records).
132    #[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// ============================================================================
149// Discovery types (repo scanning)
150// ============================================================================
151
152/// Discoverable skill (from GitHub repos).
153#[derive(Debug, Clone, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct DiscoverableSkill {
156    /// Unique key: "owner/name:directory"
157    pub key: String,
158    pub name: String,
159    pub description: String,
160    /// Directory name (the final path segment)
161    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/// CLI-friendly skill object (discoverable + installed flag).
173#[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/// Skill metadata extracted from SKILL.md YAML front matter.
192#[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
375// ============================================================================
376// SkillService
377// ============================================================================
378
379pub 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    // ---------------------------------------------------------------------
416    // Paths
417    // ---------------------------------------------------------------------
418
419    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        // Override directories follow the same pattern as upstream: <override>/skills
427        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    // ---------------------------------------------------------------------
479    // Storage (SQLite + settings.json)
480    // ---------------------------------------------------------------------
481
482    pub fn load_index() -> Result<SkillsIndex, AppError> {
483        let db = Database::init()?;
484
485        // Ensure default repos exist (insert-missing only).
486        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    // ---------------------------------------------------------------------
526    // One-time SSOT migration (scan app dirs -> copy to SSOT -> record in index)
527    // ---------------------------------------------------------------------
528
529    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        // Safety guard (upstream-aligned):
539        // - If we already have managed skills in the index, do NOT auto-import everything
540        //   from app dirs (that could unexpectedly "claim" user directories as managed).
541        // - Instead, only try to populate SSOT for the already-managed skills (best effort),
542        //   then clear the pending flag.
543        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                // Prefer looking in apps where this skill is enabled; fallback to all apps.
551                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                        // Backfill metadata if missing.
578                        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                // Copy to SSOT if needed.
631                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        // Upsert index records.
644        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    // ---------------------------------------------------------------------
697    // Sync / remove (file operations)
698    // ---------------------------------------------------------------------
699
700    #[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        // D5: allow creating target app dirs during skills sync.
758        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    /// Best-effort sync for live-flow triggers (provider switch etc).
809    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        // Prefer exact directory match.
883        if index.skills.contains_key(trimmed) {
884            return Some(trimmed.to_string());
885        }
886
887        // Case-insensitive directory match.
888        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        // Match by id.
898        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        // Remove from app dirs (best effort).
951        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        // Remove from SSOT.
964        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        // Resolve spec to a discoverable skill.
985        let discoverable = self.resolve_install_spec(&index, spec).await?;
986
987        // Directory install name is always the last segment.
988        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        // Conflict check (directory collisions across repos).
994        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            // Already installed: just enable current app and sync.
1022            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        // Ensure SSOT dir and install files.
1031        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        // If the user provides full key (owner/name:dir), match by key.
1112        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        // Otherwise treat as directory name (may be ambiguous).
1119        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    // ---------------------------------------------------------------------
1134    // Unmanaged scan / import
1135    // ---------------------------------------------------------------------
1136
1137    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    // ---------------------------------------------------------------------
1271    // Repo discovery / list
1272    // ---------------------------------------------------------------------
1273
1274    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        // Add local SSOT-only skills not in repos.
1323        Self::merge_local_ssot_skills(&index, &mut out)?;
1324
1325        // De-dup + sort.
1326        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}