orca.ai/pkg/skill/parser.go
大森 e18dde7c15 feat: implement TUI with bubbletea and multi-agent collaboration
- Add bubbletea/lipgloss/glamour dependencies for TUI
- Create internal/tui package with EventWriter, styles, and bubbletea Model
- Support streaming output display in conversation window
- Add right panel with statistics and active agent status
- Implement multi-agent collaboration with sub-agents
- Add AgentCallTool for delegating tasks to sub-agents
- Support parallel tool execution with goroutines
- Auto-discover sub-agents from ~/.orca/prompts/ directory
- Fix orchestrator routing based on msg.To field
- Add non-blocking event writer with timeout to prevent blocking
2026-05-10 14:28:17 +08:00

248 lines
5.9 KiB
Go

// Package skill 提供 Skill 定义和管理系统。
package skill
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// frontmatterDelim 是 SKILL.md 文件中 YAML 前置元数据的分隔符。
const (
frontmatterDelim = "---"
)
// ParseSkillFile 解析 SKILL.md 文件并返回填充好的 Skill 结构体。
//
// 预期格式为:
//
// ---
// name: my-skill
// description: Does something useful
// triggers: ["keyword1", "keyword2"]
// ---
//
// # My Skill
//
// Detailed description...
func ParseSkillFile(path string) (*Skill, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("skill: cannot read %q: %w", path, err)
}
return ParseSkillData(path, data)
}
// ParseSkillData 从原始字节解析 SKILL.md 内容。
// path 参数用于定位 scripts/ 目录。
func ParseSkillData(path string, data []byte) (*Skill, error) {
content := string(data)
skill := &Skill{
Path: path,
Body: content,
Triggers: []string{},
}
// Parse YAML frontmatter
rest, err := parseFrontmatter(content, skill)
if err != nil {
return nil, err
}
skill.Body = strings.TrimSpace(rest)
// Validate required fields
if skill.Name == "" {
return nil, fmt.Errorf("skill: %q is missing 'name' in frontmatter", path)
}
// Discover scripts directory
skillDir := filepath.Dir(path)
scriptsDir := filepath.Join(skillDir, "scripts")
skill.ScriptsDir = scriptsDir
if info, err := os.Stat(scriptsDir); err == nil && info.IsDir() {
scripts, err := discoverScripts(scriptsDir)
if err != nil {
return nil, fmt.Errorf("skill: failed to discover scripts in %q: %w", scriptsDir, err)
}
skill.Scripts = scripts
}
return skill, nil
}
// parseFrontmatter 提取由 "---" 行分隔的 YAML 前置元数据
// 并填充 Skill 结构体字段。
func parseFrontmatter(content string, skill *Skill) (string, error) {
content = strings.TrimSpace(content)
if !strings.HasPrefix(content, frontmatterDelim) {
// No frontmatter — treat entire content as body
return content, nil
}
// Find the closing delimiter
rest := content[len(frontmatterDelim):]
rest = strings.TrimLeft(rest, "\n\r")
endIdx := strings.Index(rest, "\n"+frontmatterDelim)
if endIdx < 0 {
// Also check for end-of-file style
endIdx = strings.Index(rest, frontmatterDelim)
if endIdx < 0 {
return "", fmt.Errorf("skill: unclosed frontmatter in skill file")
}
}
frontmatter := rest[:endIdx]
body := rest[endIdx+len(frontmatterDelim)+1:]
// Parse the YAML frontmatter (simple key-value parser)
if err := parseSimpleYAML(frontmatter, skill); err != nil {
return "", err
}
return body, nil
}
// parseSimpleYAML 解析技能前置元数据的简化 YAML 格式。
// 支持:字符串值、带引号的字符串和数组值。
func parseSimpleYAML(yaml string, skill *Skill) error {
lines := strings.Split(yaml, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
colonIdx := strings.Index(line, ":")
if colonIdx < 0 {
continue
}
key := strings.TrimSpace(line[:colonIdx])
value := strings.TrimSpace(line[colonIdx+1:])
switch key {
case "name":
skill.Name = trimQuotes(value)
case "description":
skill.Description = trimQuotes(value)
case "triggers":
triggers, err := parseYAMLArray(value)
if err != nil {
return fmt.Errorf("skill: invalid triggers format: %w", err)
}
skill.Triggers = triggers
}
}
return nil
}
// parseYAMLArray 解析 YAML 数组,如 '["a", "b", "c"]' 或 '[a, b, c]'。
func parseYAMLArray(value string) ([]string, error) {
value = strings.TrimSpace(value)
// Handle YAML list format: ["a", "b"] or [a, b]
if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
inner := value[1 : len(value)-1]
if strings.TrimSpace(inner) == "" {
return []string{}, nil
}
parts := splitCommas(inner)
result := make([]string, len(parts))
for i, p := range parts {
result[i] = trimQuotes(strings.TrimSpace(p))
}
return result, nil
}
// Handle YAML list format with dashes:
// triggers:
// - browser
// - navigate
// (This would be handled line-by-line in a different flow)
// For now, treat single value as a one-element list
if value != "" && value != "[]" {
return []string{trimQuotes(value)}, nil
}
return []string{}, nil
}
// splitCommas 分割逗号分隔的字符串,尊重引号部分。
func splitCommas(s string) []string {
var parts []string
var current strings.Builder
inQuote := false
quoteChar := byte(0)
for i := 0; i < len(s); i++ {
c := s[i]
if inQuote {
current.WriteByte(c)
if c == quoteChar {
inQuote = false
}
} else if c == '"' || c == '\'' {
current.WriteByte(c)
inQuote = true
quoteChar = c
} else if c == ',' {
parts = append(parts, current.String())
current.Reset()
} else {
current.WriteByte(c)
}
}
if current.Len() > 0 {
parts = append(parts, current.String())
}
return parts
}
// trimQuotes 移除字符串值周围的引号。
func trimQuotes(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
return s[1 : len(s)-1]
}
}
return s
}
// discoverScripts 列出 scripts 目录中所有可执行/可读的文件。
func discoverScripts(scriptsDir string) ([]string, error) {
entries, err := os.ReadDir(scriptsDir)
if err != nil {
return nil, err
}
var scripts []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
scripts = append(scripts, entry.Name())
}
sort.Strings(scripts)
return scripts, nil
}
// LoadSkillFromDir 从包含 SKILL.md 文件的目录加载技能。
func LoadSkillFromDir(dir string) (*Skill, error) {
skillPath := filepath.Join(dir, "SKILL.md")
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
return nil, fmt.Errorf("skill: no SKILL.md found in %q", dir)
}
return ParseSkillFile(skillPath)
}