Files
Han-Qing Shi 1402ba3bee fix(skills): scope workspace discovery, honor Codex catalog, and fix Pi roots (#1842)
* fix(skills): scope discovery to workspace and include Codex system skills

* fix(skills): honor Codex native enabled catalog per workspace

* fix(pi): discover native and shared workspace skills
2026-09-20 22:03:34 +08:00

314 lines
8.1 KiB
Go

package core
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
)
// Skill represents an agent skill discovered from a SKILL.md file.
type Skill struct {
Name string // skill name (= subdirectory name)
DisplayName string // optional display name from frontmatter
Description string // from frontmatter or first line of content
Prompt string // the instruction content (body after frontmatter)
Source string // directory path where this skill was found
}
// SkillRegistry discovers and caches agent skills from skill directories.
// Skills are project-level: each Engine has its own SkillRegistry.
type SkillRegistry struct {
mu sync.RWMutex
dirs []string
// cached results; nil means not yet scanned
cache []*Skill
}
func NewSkillRegistry() *SkillRegistry {
return &SkillRegistry{}
}
// SetSkills installs an authoritative snapshot, including an empty catalog.
func (r *SkillRegistry) SetSkills(skills []*Skill) {
r.mu.Lock()
defer r.mu.Unlock()
r.dirs = nil
r.cache = append([]*Skill{}, skills...)
}
// LoadSkillFile reads one explicitly selected skill without scanning siblings.
func LoadSkillFile(path string) (*Skill, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("skill: read %s: %w", path, err)
}
dir := filepath.Dir(path)
skill := parseSkillMD(filepath.Base(dir), string(data), dir)
if skill == nil {
return nil, fmt.Errorf("skill: empty instructions in %s", path)
}
return skill, nil
}
// SetDirs configures which directories to scan for skills.
func (r *SkillRegistry) SetDirs(dirs []string) {
r.mu.Lock()
defer r.mu.Unlock()
r.dirs = dirs
r.cache = nil
}
// Resolve looks up a skill by name. Returns nil if not found.
// Hyphens and underscores are treated as equivalent so that Telegram-sanitized
// names (e.g. "calendar_scheduler") match original skill names ("calendar-scheduler").
func (r *SkillRegistry) Resolve(name string) *Skill {
norm := normalizeCommandName(name)
for _, s := range r.ListAll() {
if normalizeCommandName(s.Name) == norm {
return s
}
}
return nil
}
// normalizeCommandName folds case and treats hyphens/underscores as equivalent.
func normalizeCommandName(s string) string {
return strings.ToLower(strings.ReplaceAll(s, "-", "_"))
}
// ListAll returns all discovered skills. Results are cached after first scan.
func (r *SkillRegistry) ListAll() []*Skill {
r.mu.RLock()
if r.cache != nil {
defer r.mu.RUnlock()
return r.cache
}
r.mu.RUnlock()
r.mu.Lock()
defer r.mu.Unlock()
// double-check after acquiring write lock
if r.cache != nil {
return r.cache
}
var result []*Skill
seen := make(map[string]bool)
for _, dir := range r.dirs {
result = append(result, discoverSkillsInDir(dir, seen)...)
}
r.cache = result
return result
}
// discoverSkillsInDir scans a single skill root directory for immediate
// subdirectories that contain a SKILL.md file. Per the Claude Code CLI
// convention (issue #1304), only depth-1 layout is recognised:
//
// <root>/<skill-name>/SKILL.md — registered
// <root>/<skill-name>/references/... — asset, NOT registered
//
// Nested SKILL.md files inside a skill (e.g. example templates shipped
// alongside the real one) are treated as skill assets and ignored, so they
// cannot leak into platform command menus as phantom slash commands.
func discoverSkillsInDir(scanRoot string, seen map[string]bool) []*Skill {
entries, err := os.ReadDir(scanRoot)
if err != nil {
return nil
}
var result []*Skill
for _, entry := range entries {
if !isDiscoverableSubdir(scanRoot, entry) {
continue
}
skillName := entry.Name()
if seen[strings.ToLower(skillName)] {
continue
}
skillDir := filepath.Join(scanRoot, skillName)
skillFile := filepath.Join(skillDir, "SKILL.md")
data, err := os.ReadFile(skillFile)
if err != nil {
// No SKILL.md at the top of this subdir — and we deliberately
// do NOT recurse, matching the Claude Code CLI rule.
continue
}
skill := parseSkillMD(skillName, string(data), skillDir)
if skill == nil {
continue
}
seen[strings.ToLower(skillName)] = true
result = append(result, skill)
slog.Debug("skill: discovered", "name", skillName, "dir", skillDir)
}
return result
}
// isDiscoverableSubdir reports whether entry should be considered as a
// potential skill subdirectory of parentDir. Accepts real directories and
// symlinks that resolve to directories.
func isDiscoverableSubdir(parentDir string, entry os.DirEntry) bool {
if entry.IsDir() {
return true
}
if entry.Type()&os.ModeSymlink == 0 {
return false
}
info, err := os.Stat(filepath.Join(parentDir, entry.Name()))
return err == nil && info.IsDir()
}
// Dirs returns the configured skill directories.
func (r *SkillRegistry) Dirs() []string {
r.mu.RLock()
defer r.mu.RUnlock()
return append([]string(nil), r.dirs...)
}
// Invalidate clears the cache so skills will be re-scanned on next access.
func (r *SkillRegistry) Invalidate() {
r.mu.Lock()
defer r.mu.Unlock()
r.cache = nil
}
// parseSkillMD parses a SKILL.md file with optional YAML frontmatter.
//
// Format:
//
// ---
// description: Short description
// name: Display Name
// ---
// Prompt/instruction content here...
func parseSkillMD(skillName, raw, sourceDir string) *Skill {
content := strings.TrimSpace(raw)
if content == "" {
return nil
}
var frontmatter map[string]string
body := content
if strings.HasPrefix(content, "---") {
rest := content[3:]
endIdx := strings.Index(rest, "\n---")
if endIdx >= 0 {
fmBlock := rest[:endIdx]
body = strings.TrimSpace(rest[endIdx+4:])
frontmatter = parseFrontmatter(fmBlock)
}
}
if body == "" {
return nil
}
description := ""
displayName := ""
if frontmatter != nil {
description = frontmatter["description"]
displayName = frontmatter["name"]
}
if description == "" {
first, _, _ := strings.Cut(body, "\n")
first = strings.TrimSpace(first)
if len([]rune(first)) > 80 {
first = string([]rune(first)[:80]) + "..."
}
description = first
}
return &Skill{
Name: skillName,
DisplayName: displayName,
Description: description,
Prompt: body,
Source: sourceDir,
}
}
// parseFrontmatter extracts simple key: value pairs from a YAML-like block.
// Handles quoted values, and YAML block scalar indicators (>-, |-, >, |)
// by reading the following indented lines as the value.
func parseFrontmatter(block string) map[string]string {
m := make(map[string]string)
lines := strings.Split(block, "\n")
for i := 0; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
// Handle YAML block scalar indicators: >-, |-, >, |
if val == ">-" || val == "|-" || val == ">" || val == "|" {
var blockLines []string
for i+1 < len(lines) {
next := lines[i+1]
// Block continues while lines are indented (start with space/tab)
if len(next) == 0 || (next[0] != ' ' && next[0] != '\t') {
break
}
i++
blockLines = append(blockLines, strings.TrimSpace(next))
}
val = strings.Join(blockLines, " ")
}
val = strings.Trim(val, `"'`)
if key != "" {
m[strings.ToLower(key)] = val
}
}
return m
}
// BuildSkillInvocationPrompt constructs the message sent to the agent when
// a user invokes a skill. Instead of raw prompt expansion, we instruct the
// agent to execute the skill.
func BuildSkillInvocationPrompt(skill *Skill, args []string) string {
var sb strings.Builder
sb.WriteString("The user is asking you to execute the following skill.\n\n")
name := skill.DisplayName
if name == "" {
name = skill.Name
}
fmt.Fprintf(&sb, "## Skill: %s\n", name)
if skill.Description != "" {
fmt.Fprintf(&sb, "## Description: %s\n", skill.Description)
}
sb.WriteString("\n## Skill Instructions:\n")
sb.WriteString(skill.Prompt)
if len(args) > 0 {
sb.WriteString("\n\n## User Arguments:\n")
sb.WriteString(strings.Join(args, " "))
}
sb.WriteString("\n\nPlease follow the skill instructions above to complete the task.")
return sb.String()
}