package engine // skill_loader.go 负责从文件系统发现和加载 Skill 文件. // // Skill 文件格式(两种,均支持): // 1. 子目录格式(推荐):skills/skill-name/SKILL.md // 2. 扁平格式(向后兼容):skills/skill-name.md // // 文件头是 YAML frontmatter(---...---),之后是 Markdown 正文(作为提示词模板). // 模板变量: // // $ARGUMENTS → 调用时传入的参数字符串 // ${FLYTO_SKILL_DIR} → Skill 文件所在目录的绝对路径 // ${FLYTO_SESSION_ID} → 当前会话 ID(从 context 取,可能为空) // // 设计决策: // - 零外部依赖:YAML frontmatter 用手写 line-by-line 解析器处理(不依赖 YAML 库) // - 懒加载:ScanSkillsDir 在首次调用时扫描,后续用 mtime 检测变化 // - 两种目录格式并存:支持现有 .flyto/skills/*.md 和 Claude Code 兼容的子目录格式 // // 升华改进(ELEVATED): 早期实现只支持子目录格式(name/SKILL.md). // 我们同时支持扁平格式,向后兼容旧版 .claude/skills/*.md 文件布局. // 替代方案:只支持一种格式(简单但破坏兼容性). import ( "fmt" "os" "path/filepath" "strings" ) // ExecutionContext 定义 Skill 的执行方式. // // 精妙之处(CLEVER): 用具名字符串类型而非 bool 标志-- // 未来可以扩展为 "inline" | "fork" | "background" | "stream" // 而不需要修改调用方代码(open-closed 原则). type ExecutionContext string const ( // ExecutionContextInline 在当前对话中内联展开(默认). // Skill 提示词作为工具结果返回,LLM 在同一轮 context 中处理. ExecutionContextInline ExecutionContext = "inline" // ExecutionContextFork 在独立子 Agent 中运行. // 子 Agent 有独立的消息历史和 token 预算,完成后返回结果. ExecutionContextFork ExecutionContext = "fork" ) // Skill 是一个已加载的技能定义. // // 精妙之处(CLEVER): Content 字段同时用于两种场景-- // (1) FormatAsPrompt() 用于生成系统提示词片段(向 LLM 描述 Skill 的用途) // (2) ExpandPrompt() 用于运行时模板展开(调用时作为提示词注入) // 这样一个字段覆盖两种用途,避免 Content 与 PromptTemplate 的冗余维护. type Skill struct { // Name 技能名称(调用时使用的标识符) Name string // Description 技能描述(用于 LLM 决策"何时调用此 Skill") Description string // WhenToUse 详细的使用场景说明 WhenToUse string // AllowedTools 此技能限制使用的工具列表(空 = 不限制) AllowedTools []string // Content 技能正文(Markdown,兼作提示词模板) Content string // Source 技能来源:"bundled" | "user" | "project" | "plugin" Source string // FilePath is the absolute path of the skill source file ("" = in-memory // registration). Wire: SkillRegistry.Invoke emits a skill_invoked observer // event whose payload carries this value verbatim (empty string is kept, // NOT omitted) so audit pipelines can distinguish in-memory Skills ("") from // disk-loaded ones. // // FilePath 是 skill 源文件的绝对路径 ("" = 内存注册). Wire 路径: // SkillRegistry.Invoke 发 skill_invoked 观测事件, payload 原样携带本值 (空串 // 显式保留, **非** omitempty), 审计管道据此区分 in-memory Skill ("") 和磁盘 // 加载的 skill. FilePath string // SkillDir 技能目录路径(用于 ${FLYTO_SKILL_DIR} 替换) SkillDir string // ---- 执行配置 ---- // Context 执行模式(默认 inline) Context ExecutionContext // AgentType selects the Agent type used for fork-mode execution ("" = // engine's general-purpose default). Wire: invokeFork resolves this name // via SkillRegistry.agentRegistry and delivers the FULL promise of // "SubAgent behaves as this Agent type": // - cfg.AllowedTools = ResolveToolset(def, parentTools) four-layer filter // (parent ∩ def.AllowedTools, Background narrow, minus DisallowedTools // and globalDisallowed). MCP tools auto-pass the whitelist. // - If skill itself declares AllowedTools, intersect with the resolved // set — skill cannot widen beyond what the Agent type permits. // - cfg.Model falls back to def.Model when skill.Model is "". // - cfg.MaxTurns falls back to def.MaxTurns when skill.MaxTurns is 0; // both zero → default 10. // - cfg.AllowedSubAgentTypes = def.AllowedSubAgentTypes. // On miss (typo): emit skill_fork_unknown_agent_type diagnostic then // fall back to the empty-AgentType branch (no resolution). Silent // fallback would hide typos, hard error would break UX. // // AgentType 指定 fork 模式下使用的 Agent 类型 ("" = 引擎 general-purpose // 默认). Wire 路径: invokeFork 经 SkillRegistry.agentRegistry 解析该名称, // 命中后完整兑现 "SubAgent 表现成该 Agent 类型" 承诺: // - cfg.AllowedTools = ResolveToolset(def, parentTools) 四层过滤 // (父工具 ∩ def.AllowedTools → Background 收窄 → 去 // DisallowedTools ∪ globalDisallowed). MCP 工具自动通过白名单. // - skill 自己声明了 AllowedTools 时, 与上述结果取交集 -- skill 不能 // 扩张到 Agent 类型允许之外的工具. // - cfg.Model 在 skill.Model 为 "" 时 fallback 到 def.Model. // - cfg.MaxTurns 在 skill.MaxTurns 为 0 时 fallback 到 def.MaxTurns; // 两者都 0 → 默认 10. // - cfg.AllowedSubAgentTypes = def.AllowedSubAgentTypes. // 未命中 (拼写错): 发 skill_fork_unknown_agent_type 诊断事件后 fallback // 到空串分支 (不解析). 静默 fallback 会隐藏拼写错误, 硬报错又会破坏 UX. AgentType string // Model 模型覆盖(空 = 继承父 Engine 的模型) Model string // MaxTurns fork 模式下的最大轮数(0 = 默认 10) MaxTurns int // ---- 发现/激活 ---- // Paths glob 激活过滤(P1):仅当 CWD 前缀匹配或 glob 匹配时激活此 Skill. // 例如:["src/*.ts", "src/components/**"] 表示只在匹配目录下激活. // // 升华改进(ELEVATED): 早期实现 paths 对照的是"已读过的文件"; // 我们的实现对照 CWD(更实时,更轻量,不需要查历史 I/O 记录). // CWD 前缀匹配 + filepath.Match glob 双模式: // 前缀匹配:Paths = ["src/"] → CWD 以 src/ 打头时激活. // glob 匹配:Paths = ["*/frontend/**"] → filepath.Match 判断. // 两种模式 OR 关系--任意一条命中即激活. // 替代方案:<原 LEGACY 方案:字段已解析但暂不生效> - 已废弃. Paths []string // Version is the semantic-version string declared in the skill's YAML // frontmatter (e.g. "1.0.0"), free-form (no parse-time validation). Wire: // SkillRegistry.Invoke carries this value in the skill_invoked observer // event so audit / telemetry can correlate each invocation with the // skill revision that was live at the time. Future uses (cache // invalidation, version gating) are intentionally NOT implemented here — // the minimal commitment is "every invocation records which version ran". // // Version 是 skill YAML frontmatter 声明的语义化版本字符串 (如 "1.0.0"), // 自由格式不在加载期校验. Wire 路径: SkillRegistry.Invoke 把本值写进 // skill_invoked 观测事件, 审计 / telemetry 据此把每次调用和当时在册的 skill // 修订对齐. 缓存失效 / 版本 gate 等进一步用途故意暂不实现 -- 当前最小承诺 // 是"每次调用都记录了跑的是哪一版". Version string // UserInvocable 是否允许用户通过 /skill-name 直接调用 UserInvocable bool // ArgumentHint 参数提示(在 slash 命令补全中显示) ArgumentHint string } // FormatAsPrompt 将技能格式化为系统提示词片段(用于描述 Skill). func (s *Skill) FormatAsPrompt() string { var b strings.Builder b.WriteString("## Skill: ") b.WriteString(s.Name) b.WriteString("\n") if s.Description != "" { b.WriteString("Description: ") b.WriteString(s.Description) b.WriteString("\n") } if s.WhenToUse != "" { b.WriteString("When to use: ") b.WriteString(s.WhenToUse) b.WriteString("\n") } if len(s.AllowedTools) > 0 { b.WriteString("Allowed tools: ") b.WriteString(strings.Join(s.AllowedTools, ", ")) b.WriteString("\n") } if s.ArgumentHint != "" { b.WriteString("Arguments: ") b.WriteString(s.ArgumentHint) b.WriteString("\n") } b.WriteString("\n") if s.Content != "" { b.WriteString(s.Content) b.WriteString("\n") } return b.String() } // ExpandPrompt 展开提示词模板,替换变量. // // 支持的模板变量: // // $ARGUMENTS → 传入的参数字符串 // ${FLYTO_SKILL_DIR} → Skill 文件目录路径 // ${FLYTO_SESSION_ID} → 会话 ID // // 精妙之处(CLEVER): 顺序替换而非正则-- // $ARGUMENTS 先替换,避免参数内容被错误地识别为其他变量名. // 如果参数值包含 "${FLYTO_SKILL_DIR}",也只是文本,不会二次展开. func (s *Skill) ExpandPrompt(args, sessionID string) string { result := s.Content result = strings.ReplaceAll(result, "$ARGUMENTS", args) result = strings.ReplaceAll(result, "${FLYTO_SKILL_DIR}", s.SkillDir) result = strings.ReplaceAll(result, "${FLYTO_SESSION_ID}", sessionID) // 兼容早期实现 变量名 result = strings.ReplaceAll(result, "${CLAUDE_SKILL_DIR}", s.SkillDir) result = strings.ReplaceAll(result, "${CLAUDE_SESSION_ID}", sessionID) return result } // MatchesCwd 判断此 Skill 是否在给定的工作目录下激活. // // 规则: // 1. Paths 为空 → 无限制,任何目录都激活(返回 true). // 2. 逐条检查 Paths 元素: // a. 前缀匹配:如果 cwd 以 path(CleanPath 后)开头 → 激活. // b. glob 匹配:filepath.Match(pattern, cwd) == true → 激活. // c. 任意一条命中即返回 true(OR 关系). // // 精妙之处(CLEVER): 前缀 + glob 双模式-- // 前缀模式直观(paths: ["src/"]),glob 模式强大(paths: ["*/frontend/**"]). // filepath.Match 已处理通配符,前缀检查覆盖目录树匹配,两者互补. // 替代方案:只用 glob("src/**" 比 "src/" 冗长,用户体验差). func (s *Skill) MatchesCwd(cwd string) bool { // ELEVATED: Paths 为空 = 全局激活,向后兼容现有未设置 Paths 的 Skill. if len(s.Paths) == 0 { return true } cleanCwd := filepath.Clean(cwd) for _, pattern := range s.Paths { cleanPattern := filepath.Clean(pattern) // 前缀匹配:cwd 以 pattern 目录开头 // 例如:pattern="src",cwd="/project/src/components" → 前缀不含路径分隔符的情况 // 需要确保匹配的是完整路径段,不是局部字符串 if strings.HasPrefix(cleanCwd+string(filepath.Separator), cleanPattern+string(filepath.Separator)) { return true } // 精妙之处(CLEVER): glob 匹配前先尝试路径段精确匹配-- // filepath.Match("src", "/project/src") 不会匹配(pattern 无前导 /). // 所以 glob 适合 "*/frontend/**" 这类含通配符的场景, // 不含通配符的简短路径用前缀匹配更可靠. // glob 匹配 if matched, err := filepath.Match(cleanPattern, cleanCwd); err == nil && matched { return true } } return false } // FormatSkillsPrompt 将多个技能格式化为一段系统提示词. // maxChars 控制最大字节数(0 = 不限制). // // 升华改进(ELEVATED): 早期实现 用 1% 上下文 token 预算动态截断. // 我们用固定字节上限(默认 8000),更简单且与 Token 计算解耦. // 替代方案:<传入 tokenBudget 参数> - P1 改进,当前 P0 用固定值. func FormatSkillsPrompt(skills []*Skill, maxChars int) string { if len(skills) == 0 { return "" } var b strings.Builder b.WriteString("\n# Available Skills\n\n") b.WriteString("The following skills are available. Invoke them using the Skill tool:\n\n") for _, s := range skills { entry := s.FormatAsPrompt() if maxChars > 0 && b.Len()+len(entry) > maxChars { // 超出预算:不再追加,避免撑爆上下文 break } b.WriteString(entry) b.WriteString("\n") } return b.String() } // ScanSkillsDir 扫描指定目录中的所有 Skill 文件. // // 支持两种格式: // 1. 子目录格式:dir/skill-name/SKILL.md // 2. 扁平格式:dir/skill-name.md // // 参数: // - dir: 技能目录路径(不存在则返回空列表,不报错) // - source: 来源标识("bundled" | "user" | "project" | "plugin") // // 精妙之处(CLEVER): 扁平格式和子目录格式可以共存于同一目录. // 若同名(skill-name.md 和 skill-name/SKILL.md),子目录格式优先. // 原因:子目录格式是新格式(与早期实现 兼容),扁平是遗留格式. func ScanSkillsDir(dir, source string) ([]*Skill, error) { entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil, nil // 目录不存在不是错误 } return nil, err } skillMap := make(map[string]*Skill) // name → Skill(去重,子目录优先) for _, entry := range entries { if entry.IsDir() { // 尝试子目录格式:dir/entry-name/SKILL.md skillFile := filepath.Join(dir, entry.Name(), "SKILL.md") if s, err := LoadSkillFile(skillFile, source); err == nil { // 子目录格式:name = 目录名 if s.Name == "" { s.Name = entry.Name() } skillMap[s.Name] = s // 子目录格式优先覆盖扁平格式 } } else { name := entry.Name() if !strings.HasSuffix(name, ".md") { continue } // 扁平格式:dir/skill-name.md filePath := filepath.Join(dir, name) s, err := LoadSkillFile(filePath, source) if err != nil { continue // 解析失败,跳过 } if s.Name == "" { s.Name = strings.TrimSuffix(name, ".md") } // 只在没有同名子目录 Skill 时才加入 if _, exists := skillMap[s.Name]; !exists { skillMap[s.Name] = s } } } result := make([]*Skill, 0, len(skillMap)) for _, s := range skillMap { result = append(result, s) } return result, nil } // LoadSkillFile 从单个文件加载并解析 Skill 定义. // // 参数: // - path: .md 文件的绝对路径 // - source: 来源标识 func LoadSkillFile(path, source string) (*Skill, error) { // 安全(CLEVER): 路径穿越防御. // 将 path 转为绝对路径后检查是否包含 ".." 组件. // Clean 会规范化路径(如 /a/b/../c → /a/c), // 但如果 Clean 后仍含 "..",说明路径试图逃逸到根目录之上(理论上不可能,但防御性检查). // 同时拒绝相对路径--调用方应始终传入绝对路径. cleaned := filepath.Clean(path) if !filepath.IsAbs(cleaned) { return nil, fmt.Errorf("skill file path must be absolute: %s", path) } // filepath.Clean 后正常路径不会包含 ".." 组件, // 但仍做显式检查以防未来 Go 行为变化. for _, part := range strings.Split(cleaned, string(filepath.Separator)) { if part == ".." { return nil, fmt.Errorf("skill file path contains directory traversal: %s", path) } } path = cleaned data, err := os.ReadFile(path) if err != nil { return nil, err } skill := &Skill{ Source: source, FilePath: path, SkillDir: filepath.Dir(path), } content := string(data) fm, body := parseFrontmatter(content) skill.Content = strings.TrimSpace(body) if fm != "" { parseFrontmatterFields(fm, skill) } return skill, nil } // ---- YAML frontmatter 解析 ---- // // 精妙之处(CLEVER): 不使用 YAML 库,手写行扫描器. // 原因:(1) 零外部依赖原则;(2) Skill frontmatter 的 YAML 子集很小. // 支持的格式: // key: value → string // key: value1, value2 → []string(逗号分隔) // key: "quoted value" → string(去引号) // key: → 后续 "- item" 行构成 []string // - item → 属于上一个 key 的列表项 // 不支持:嵌套对象,多行字符串,YAML 锚点等复杂特性. // 这对 Skill frontmatter 完全够用. // parseFrontmatter 提取 YAML frontmatter 和正文. // frontmatter 格式:文件以 "---\n" 开头,以 "\n---" 结尾. func parseFrontmatter(content string) (frontmatter string, body string) { trimmed := strings.TrimSpace(content) if !strings.HasPrefix(trimmed, "---") { return "", content } // 跳过第一行 "---" rest := trimmed[3:] if idx := strings.Index(rest, "\n"); idx >= 0 { rest = rest[idx+1:] } else { return "", content } // 找结束标记 "\n---" endIdx := strings.Index(rest, "\n---") if endIdx < 0 { return "", content } frontmatter = rest[:endIdx] body = rest[endIdx+4:] // 跳过 "\n---" // 跳过结束标记后的换行 if len(body) > 0 && body[0] == '\n' { body = body[1:] } return frontmatter, body } // parseFrontmatterFields 从 YAML frontmatter 文本中解析字段并填充到 Skill. func parseFrontmatterFields(fm string, skill *Skill) { lines := strings.Split(fm, "\n") currentKey := "" for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } // 列表项(属于上一个 key) if strings.HasPrefix(trimmed, "- ") { value := strings.TrimSpace(trimmed[2:]) value = unquoteYAML(value) applyFrontmatterListItem(currentKey, value, skill) continue } // key: value 格式 colonIdx := strings.Index(trimmed, ":") if colonIdx < 0 { continue } key := strings.TrimSpace(trimmed[:colonIdx]) rawValue := strings.TrimSpace(trimmed[colonIdx+1:]) currentKey = key // 空值(后面跟列表) if rawValue == "" || rawValue == "|" || rawValue == ">" { continue } value := unquoteYAML(rawValue) applyFrontmatterField(key, value, skill) } } // applyFrontmatterField 将单个 key-value 应用到 Skill. func applyFrontmatterField(key, value string, skill *Skill) { switch key { case "name": skill.Name = value case "description": skill.Description = value case "when_to_use", "whenToUse", "when-to-use": skill.WhenToUse = value case "argument-hint", "argumentHint", "argument_hint": skill.ArgumentHint = value case "version": skill.Version = value case "model": if value != "inherit" { skill.Model = value } case "context": switch value { case "fork": skill.Context = ExecutionContextFork default: skill.Context = ExecutionContextInline } case "agent": skill.AgentType = value case "user-invocable", "userInvocable", "user_invocable": skill.UserInvocable = value == "true" || value == "yes" || value == "1" case "hide-from-slash-command-tool": // 如果设置了 hide,等同于 user-invocable: false if value == "true" { skill.UserInvocable = false } case "allowed-tools", "allowed_tools", "allowedTools": // 单行逗号分隔格式:allowed-tools: Bash, Read, Write if value != "" { parts := splitCSV(value) skill.AllowedTools = append(skill.AllowedTools, parts...) } case "paths": // 单行逗号分隔格式:paths: src/*.ts, src/*.tsx if value != "" { parts := splitCSV(value) skill.Paths = append(skill.Paths, parts...) } } } // applyFrontmatterListItem 将列表项应用到对应 key. func applyFrontmatterListItem(key, value string, skill *Skill) { switch key { case "allowed-tools", "allowed_tools", "allowedTools": skill.AllowedTools = append(skill.AllowedTools, value) case "paths": skill.Paths = append(skill.Paths, value) } } // unquoteYAML 去掉 YAML 字符串两侧的引号. // 支持单引号和双引号. func unquoteYAML(s string) string { 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 } // splitCSV 将逗号分隔的字符串拆分为 slice,去掉首尾空白. // // 精妙之处(CLEVER): 花括号内的逗号不拆分(支持 glob 模式如 "src/*.{ts,tsx}"). // 如果整体是单个带引号的值,也视为单项(引号暗示用户不想被拆分). // 替代方案:直接 strings.Split(",")--会把 "src/*.{ts,tsx}" 错误拆成两段. func splitCSV(s string) []string { // 去掉可能的 YAML 方括号包装 s = strings.Trim(s, "[]") var result []string depth := 0 // 花括号嵌套深度 start := 0 // 当前段的开始位置 inQuote := false var quoteChar byte for i := 0; i < len(s); i++ { ch := s[i] switch { case !inQuote && (ch == '"' || ch == '\''): inQuote = true quoteChar = ch case inQuote && ch == quoteChar: inQuote = false case !inQuote && ch == '{': depth++ case !inQuote && ch == '}': if depth > 0 { depth-- } case !inQuote && depth == 0 && ch == ',': // 花括号外的逗号 = 分隔符 part := strings.TrimSpace(s[start:i]) part = unquoteYAML(part) if part != "" { result = append(result, part) } start = i + 1 } } // 最后一段 part := strings.TrimSpace(s[start:]) part = unquoteYAML(part) if part != "" { result = append(result, part) } return result }