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;
25const MANAGED_SKILL_MARKER: &str = ".cc-switch-tui-managed";
26
27fn default_skills_index_version() -> u32 {
28    SKILLS_INDEX_VERSION
29}
30
31// ============================================================================
32// Legacy (v2) store structures - kept for backward compatibility
33// ============================================================================
34
35/// Skill repository configuration (legacy, kept for backward compatibility).
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct SkillRepo {
38    /// GitHub 用户/组织名
39    pub owner: String,
40    /// 仓库名称
41    pub name: String,
42    /// 分支 (默认 "main")
43    pub branch: String,
44    /// 是否启用
45    pub enabled: bool,
46}
47
48/// Legacy install state: directory -> installed timestamp (Claude-only era).
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SkillState {
51    /// 是否已安装
52    pub installed: bool,
53    /// 安装时间
54    #[serde(rename = "installedAt")]
55    pub installed_at: DateTime<Utc>,
56}
57
58/// Legacy persistent store (was embedded in config.json in older CLI versions).
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct SkillStore {
61    /// directory -> 安装状态
62    pub skills: HashMap<String, SkillState>,
63    /// 仓库列表
64    pub repos: Vec<SkillRepo>,
65}
66
67impl Default for SkillStore {
68    fn default() -> Self {
69        SkillStore {
70            skills: HashMap::new(),
71            // Keep aligned with upstream defaults where possible.
72            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// ============================================================================
103// New (Phase 3) SSOT-based model persisted to ~/.cc-switch-tui/skills.json (no DB)
104// ============================================================================
105
106/// Skill sync method (upstream-aligned).
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
108#[serde(rename_all = "lowercase")]
109pub enum SyncMethod {
110    /// Auto choose: prefer symlink, fallback to copy.
111    #[default]
112    Auto,
113    /// Always use symlink.
114    Symlink,
115    /// Always use directory copy.
116    Copy,
117}
118
119/// skills.json (SSOT index; no DB).
120#[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    /// directory -> record
130    #[serde(default)]
131    pub skills: HashMap<String, InstalledSkill>,
132    /// One-time SSOT migration flag (scan app dirs -> copy into SSOT -> build records).
133    #[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// ============================================================================
150// Discovery types (repo scanning)
151// ============================================================================
152
153/// Discoverable skill (from GitHub repos).
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct DiscoverableSkill {
157    /// Unique key: "owner/name:directory"
158    pub key: String,
159    pub name: String,
160    pub description: String,
161    /// Directory name (the final path segment)
162    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/// CLI-friendly skill object (discoverable + installed flag).
174#[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/// Skill metadata extracted from SKILL.md YAML front matter.
193#[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
553// ============================================================================
554// SkillService
555// ============================================================================
556
557pub 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    // ---------------------------------------------------------------------
603    // Paths
604    // ---------------------------------------------------------------------
605
606    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    // ---------------------------------------------------------------------
621    // Storage (SQLite + settings.json)
622    // ---------------------------------------------------------------------
623
624    pub fn load_index() -> Result<SkillsIndex, AppError> {
625        let db = Database::init()?;
626
627        // Ensure default repos exist (insert-missing only).
628        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    // ---------------------------------------------------------------------
702    // One-time SSOT migration (scan app dirs -> copy to SSOT -> record in index)
703    // ---------------------------------------------------------------------
704
705    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        // Safety guard (upstream-aligned):
715        // - If we already have managed skills in the index, do NOT auto-import everything
716        //   from app dirs (that could unexpectedly "claim" user directories as managed).
717        // - Instead, only try to populate SSOT for the already-managed skills (best effort),
718        //   then clear the pending flag.
719        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                // Prefer looking in apps where this skill is enabled; fallback to all apps.
727                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                        // Backfill metadata if missing.
754                        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                // Copy to SSOT if needed.
807                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        // Upsert index records.
820        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    // ---------------------------------------------------------------------
873    // Sync / remove (file operations)
874    // ---------------------------------------------------------------------
875
876    #[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        // D5: allow creating target app dirs during skills sync.
955        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    /// Best-effort sync for live-flow triggers (provider switch etc).
1020    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        // Prefer exact directory match.
1095        if index.skills.contains_key(trimmed) {
1096            return Some(trimmed.to_string());
1097        }
1098
1099        // Case-insensitive directory match.
1100        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        // Match by id.
1110        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        // Remove from app dirs (best effort).
1163        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        // Remove from SSOT.
1170        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        // Resolve spec to a discoverable skill.
1191        let discoverable = self.resolve_install_spec(&index, spec).await?;
1192
1193        // Directory install name is always the last segment.
1194        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        // Conflict check (directory collisions across repos).
1200        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            // Already installed: just enable current app and sync.
1228            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        // Ensure SSOT dir and install files.
1237        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        // If the user provides full key (owner/name:dir), match by key.
1318        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        // Otherwise treat as directory name (may be ambiguous).
1325        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    // ---------------------------------------------------------------------
1340    // Unmanaged scan / import
1341    // ---------------------------------------------------------------------
1342
1343    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    // ---------------------------------------------------------------------
1630    // Repo discovery / list
1631    // ---------------------------------------------------------------------
1632
1633    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        // Add local SSOT-only skills not in repos.
1682        Self::merge_local_ssot_skills(&index, &mut out)?;
1683
1684        // De-dup + sort.
1685        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}