package internal import ( "os" "path/filepath" "sort" "strings" "unicode/utf8" tea "charm.land/bubbletea/v2" ) // ScanSkillsCompleteMsg 技能扫描完成消息 type ScanSkillsCompleteMsg struct { Skills []*SkillInfo } // ScanSkillsCmd 扫描 ~/.claude/skills/ 目录 func ScanSkillsCmd() tea.Cmd { return func() tea.Msg { skills, err := ScanSkills() if err != nil { return ScanSkillsCompleteMsg{} } return ScanSkillsCompleteMsg{Skills: skills} } } // SkillInfo 单个技能信息 type SkillInfo struct { Name string Dir string Description string Root *SkillFileNode } // SkillFileNode 技能文件树节点 type SkillFileNode struct { Name string Path string IsDir bool Expanded bool Children []*SkillFileNode } // SkillsState 技能 tab 视图状态 type SkillsState struct { Skills []*SkillInfo SkillCur int // 技能列表光标 (panel 0) FileCur int // 文件树光标 (panel 1) FocusPanel int // 0=技能列表 1=文件树 2=内容 Content string ContentPath string ContentScroll int // 内容面板纵向滚动偏移(行) Loaded bool Scanning bool } // flatNode 文件树扁平化节点 type flatNode struct { *SkillFileNode Depth int } // ScanSkills 扫描 ~/.claude/skills/ 目录 func ScanSkills() ([]*SkillInfo, error) { home, err := os.UserHomeDir() if err != nil { return nil, err } skillsDir := filepath.Join(home, ".claude", "skills") return scanSkillsDir(skillsDir) } func scanSkillsDir(dir string) ([]*SkillInfo, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, err } var skills []*SkillInfo for _, entry := range entries { if !entry.IsDir() { continue } name := entry.Name() if strings.HasPrefix(name, ".") { continue } skillPath := filepath.Join(dir, name) info := &SkillInfo{ Name: name, Dir: skillPath, } // 读取 SKILL.md 获取描述 skillMd := filepath.Join(skillPath, "SKILL.md") if data, err := os.ReadFile(skillMd); err == nil { info.Description = extractSkillDesc(string(data)) } // 构建文件树 info.Root = buildFileTree(skillPath) if info.Root != nil { info.Root.Expanded = true // 默认展开根节点 } skills = append(skills, info) } sort.Slice(skills, func(i, j int) bool { return skills[i].Name < skills[j].Name }) return skills, nil } // extractSkillDesc 从 SKILL.md 提取描述文本 func extractSkillDesc(content string) string { lines := strings.Split(content, "\n") afterHeading := false for _, line := range lines { line = strings.TrimSpace(line) if strings.HasPrefix(line, "#") { afterHeading = true continue } // 跳过分隔线、引言、元数据 if strings.HasPrefix(line, "---") || strings.HasPrefix(line, ">") || strings.HasPrefix(line, "##") || strings.HasPrefix(line, "|") || strings.HasPrefix(line, "```") || strings.HasPrefix(line, "