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