diff --git a/internal/p9/server.go b/internal/p9/server.go index 8d7a2b2..badc678 100644 --- a/internal/p9/server.go +++ b/internal/p9/server.go @@ -500,29 +500,24 @@ func (s *Server) walk(cs *connState, fc *plan9.Fcall) *plan9.Fcall { return errFcall(fc, "not found") } } else if path == "/skills" { - // Inside skills directory - list intents + help + // Flat skill files: .md if s.skills != nil { - intents, _ := s.skills.listIntents() - qid = plan9.Qid{Type: QTFile, Path: qidSkillsBase} - if name == "help" { - newPath = "/skills/help" - } else if slices.Contains(intents, name) { - qid = plan9.Qid{Type: QTDir, Path: qidSkillsBase + hashID(name)} - newPath = "/skills/" + name - } else { + skills, _ := s.skills.listAllSkills() + found := false + for _, skill := range skills { + if name == skill.Name+".md" { + qid = plan9.Qid{Type: QTFile, Path: qidSkillsBase + hashID(name)} + newPath = "/skills/" + name + found = true + break + } + } + if !found { return errFcall(fc, "not found") } } else { return errFcall(fc, "not found") } - } else if strings.HasPrefix(path, "/skills/") && strings.Count(path, "/") == 2 { - // Inside skills/ - skill directories - qid = plan9.Qid{Type: QTDir, Path: qidSkillsBase + hashID(path+name)} - newPath = path + "/" + name - } else if strings.HasPrefix(path, "/skills/") && strings.Count(path, "/") == 3 { - // Inside skills// - files - qid = plan9.Qid{Type: QTFile, Path: qidSkillsBase + hashID(path+name)} - newPath = path + "/" + name } else if path == "/roles" { // Inside roles directory - list focus areas if s.roles != nil { @@ -1128,40 +1123,12 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte { } } else if path == "/skills" { if s.skills != nil { - // Add help file - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidSkillsBase}, - Mode: 0444, Name: "help", Uid: "q", Gid: "q", Muid: "q", - }) - // Add intent directories - intents, _ := s.skills.listIntents() - for _, intent := range intents { - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTDir, Path: qidSkillsBase + hashID(intent)}, - Mode: plan9.DMDIR | 0555, Name: intent, Uid: "q", Gid: "q", Muid: "q", - }) - } - } - } else if strings.HasPrefix(path, "/skills/") && strings.Count(path, "/") == 2 { - // Inside skills/ - list skill directories - intent := strings.TrimPrefix(path, "/skills/") - if s.skills != nil { - skills, _ := s.skills.listSkillsInIntent(intent) + // Flat: list .md files + skills, _ := s.skills.listAllSkills() for _, skill := range skills { dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTDir, Path: qidSkillsBase + hashID(path+skill.Name)}, - Mode: plan9.DMDIR | 0555, Name: skill.Name, Uid: "q", Gid: "q", Muid: "q", - }) - } - } - } else if strings.HasPrefix(path, "/skills/") && strings.Count(path, "/") == 3 { - // Inside skills// - list files - if s.skills != nil { - skillDirs, _ := s.skills.List("agent" + path) - for _, sd := range skillDirs { - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: sd.Qid.Type, Path: qidSkillsBase + hashID(path+sd.Name)}, - Mode: sd.Mode, Name: sd.Name, Uid: "q", Gid: "q", Muid: "q", + Qid: plan9.Qid{Type: QTFile, Path: qidSkillsBase + hashID(skill.Name + ".md")}, + Mode: 0444, Name: skill.Name + ".md", Uid: "q", Gid: "q", Muid: "q", }) } } diff --git a/internal/p9/skills.go b/internal/p9/skills.go index f3df319..7d0200b 100644 --- a/internal/p9/skills.go +++ b/internal/p9/skills.go @@ -5,16 +5,15 @@ import ( "fmt" "os" "path/filepath" - "slices" "sort" "strings" "9fans.net/go/plan9" ) -// SkillsFS provides virtual filesystem for skills organized by intent -// SkillsFS provides 9P filesystem access to agent skills organized by intent. -// Skills are discovered from configured directories and exposed as virtual files. +// SkillsFS provides a flat virtual filesystem for skills. +// Skills are discovered from configured directories and exposed as .md files. +// The listing is the union of all skills directories, sorted by name. type SkillsFS struct { skillsDirs []string } @@ -22,13 +21,12 @@ type SkillsFS struct { // SkillMeta holds parsed YAML front-matter from SKILL.md files. type SkillMeta struct { Name string - Intents []string Description string Path string // directory path } // NewSkillsFS creates a new skills filesystem handler. -// It discovers skills from SKILLS_DIR environment variable or default locations. +// It discovers skills from ANVILLM_SKILLS_DIR environment variable or default locations. func NewSkillsFS() *SkillsFS { var dirs []string @@ -52,7 +50,7 @@ func NewSkillsFS() *SkillsFS { return &SkillsFS{skillsDirs: dirs} } -// parseSkillFrontMatter extracts intent and description from SKILL.md YAML front-matter +// parseSkillFrontMatter extracts name and description from SKILL.md YAML front-matter. func parseSkillFrontMatter(skillDir string) (*SkillMeta, error) { skillFile := filepath.Join(skillDir, "SKILL.md") f, err := os.Open(skillFile) @@ -87,14 +85,7 @@ func parseSkillFrontMatter(skillDir string) (*SkillMeta, error) { continue } - if intents, ok := strings.CutPrefix(line, "intent:"); ok { - for i := range strings.SplitSeq(intents, ",") { - i = strings.TrimSpace(i) - if i != "" { - meta.Intents = append(meta.Intents, i) - } - } - } else if name, ok := strings.CutPrefix(line, "name:"); ok { + if name, ok := strings.CutPrefix(line, "name:"); ok { meta.Name = strings.TrimSpace(name) hasName = true } else if desc, ok := strings.CutPrefix(line, "description:"); ok { @@ -111,7 +102,8 @@ func parseSkillFrontMatter(skillDir string) (*SkillMeta, error) { return meta, nil } -// listAllSkills scans all skills directories and returns metadata +// listAllSkills scans all skills directories and returns deduplicated metadata, +// with the first occurrence (by skillsDirs order) winning on name collision. func (s *SkillsFS) listAllSkills() ([]*SkillMeta, error) { seen := make(map[string]bool) var skills []*SkillMeta @@ -142,204 +134,56 @@ func (s *SkillsFS) listAllSkills() ([]*SkillMeta, error) { } } + sort.Slice(skills, func(i, j int) bool { return skills[i].Name < skills[j].Name }) return skills, nil } -// listIntents returns unique intent names from all skills -func (s *SkillsFS) listIntents() ([]string, error) { - skills, err := s.listAllSkills() - if err != nil { - return nil, err - } - - intentSet := make(map[string]bool) - for _, skill := range skills { - if len(skill.Intents) == 0 { - intentSet["uncategorized"] = true - } else { - for _, intent := range skill.Intents { - intentSet[intent] = true - } - } - } - - var intents []string - for intent := range intentSet { - intents = append(intents, intent) - } - return intents, nil -} - -// listSkillsInIntent returns skills that have the given intent -func (s *SkillsFS) listSkillsInIntent(intent string) ([]*SkillMeta, error) { - skills, err := s.listAllSkills() - if err != nil { - return nil, err - } - - var result []*SkillMeta - for _, skill := range skills { - if intent == "uncategorized" && len(skill.Intents) == 0 { - result = append(result, skill) - continue - } - if slices.Contains(skill.Intents, intent) { - result = append(result, skill) - } - } - return result, nil -} - -// generateHelp creates aggregated index: intent/skill-name\tdescription -func (s *SkillsFS) generateHelp() (string, error) { - skills, err := s.listAllSkills() - if err != nil { - return "", err - } - - seen := make(map[string]bool) - var lines []string - for _, skill := range skills { - key := skill.Name + "\t" + skill.Description - if seen[key] { - continue - } - seen[key] = true - lines = append(lines, key) - } - sort.Strings(lines) - return strings.Join(lines, "\n") + "\n", nil -} - -// List returns directory entries for a skills path +// List returns directory entries for the flat skills root: agent/skills → .md files. func (s *SkillsFS) List(path string) ([]plan9.Dir, error) { parts := strings.Split(strings.TrimPrefix(path, "/"), "/") - // /skills - list intents + help file if len(parts) == 2 && parts[0] == "agent" && parts[1] == "skills" { - intents, err := s.listIntents() + skills, err := s.listAllSkills() if err != nil { return nil, err } - - var dirs []plan9.Dir - dirs = append(dirs, plan9.Dir{ - Name: "help", - Qid: plan9.Qid{Type: plan9.QTFILE}, - Mode: 0444, - }) - for _, intent := range intents { - dirs = append(dirs, plan9.Dir{ - Name: intent, - Qid: plan9.Qid{Type: plan9.QTDIR}, - Mode: plan9.DMDIR | 0555, - }) - } - return dirs, nil - } - - // /skills/ - list skill directories - if len(parts) == 3 && parts[0] == "agent" && parts[1] == "skills" { - intent := parts[2] - skills, err := s.listSkillsInIntent(intent) - if err != nil { - return nil, err - } - var dirs []plan9.Dir for _, skill := range skills { dirs = append(dirs, plan9.Dir{ - Name: skill.Name, - Qid: plan9.Qid{Type: plan9.QTDIR}, - Mode: plan9.DMDIR | 0555, + Name: skill.Name + ".md", + Qid: plan9.Qid{Type: plan9.QTFILE}, + Mode: 0444, }) } return dirs, nil } - // /skills// - list files in skill directory - if len(parts) == 4 && parts[0] == "agent" && parts[1] == "skills" { - intent := parts[2] - skillName := parts[3] - - skills, err := s.listSkillsInIntent(intent) - if err != nil { - return nil, err - } - - for _, skill := range skills { - if skill.Name == skillName { - entries, err := os.ReadDir(skill.Path) - if err != nil { - return nil, err - } - - var dirs []plan9.Dir - for _, entry := range entries { - mode := plan9.Perm(0444) - qtype := uint8(plan9.QTFILE) - if entry.IsDir() { - mode = plan9.DMDIR | 0555 - qtype = uint8(plan9.QTDIR) - } - dirs = append(dirs, plan9.Dir{ - Name: entry.Name(), - Qid: plan9.Qid{Type: qtype}, - Mode: mode, - }) - } - return dirs, nil - } - } - } - return nil, fmt.Errorf("not found") } -// Read returns file content for a skills path +// Read returns SKILL.md content for agent/skills/.md. func (s *SkillsFS) Read(path string) ([]byte, error) { parts := strings.Split(strings.TrimPrefix(path, "/"), "/") - // /agent/skills/help - if len(parts) == 3 && parts[0] == "agent" && parts[1] == "skills" && parts[2] == "help" { - help, err := s.generateHelp() + // agent/skills/.md + if len(parts) == 3 && parts[0] == "agent" && parts[1] == "skills" { + fileName := parts[2] + if !strings.HasSuffix(fileName, ".md") { + return nil, fmt.Errorf("not found") + } + skillName := strings.TrimSuffix(fileName, ".md") + + skills, err := s.listAllSkills() if err != nil { return nil, err } - return []byte(help), nil - } - - // /agent/skills/// - if len(parts) < 5 || parts[0] != "agent" || parts[1] != "skills" { - return nil, fmt.Errorf("not found") - } - - intent := parts[2] - skillName := parts[3] - fileName := strings.Join(parts[4:], "/") - - // Prevent path traversal - cleanName := filepath.Clean(fileName) - if filepath.IsAbs(cleanName) { - return nil, fmt.Errorf("invalid path") - } - - skills, err := s.listSkillsInIntent(intent) - if err != nil { - return nil, err - } - - for _, skill := range skills { - if skill.Name == skillName { - filePath := filepath.Join(skill.Path, cleanName) - // Verify result stays within skill directory - relPath, err := filepath.Rel(skill.Path, filePath) - if err != nil || strings.HasPrefix(relPath, "..") { - return nil, fmt.Errorf("invalid path") + for _, skill := range skills { + if skill.Name == skillName { + return os.ReadFile(filepath.Join(skill.Path, "SKILL.md")) } - return os.ReadFile(filePath) } + return nil, fmt.Errorf("skill not found: %s", skillName) } - return nil, fmt.Errorf("skill not found") + return nil, fmt.Errorf("not found") } diff --git a/mcptools/discover_skill.sh b/mcptools/discover_skill.sh index afb2502..dae505b 100755 --- a/mcptools/discover_skill.sh +++ b/mcptools/discover_skill.sh @@ -11,31 +11,16 @@ fi keyword="$1" results="" -# Search help index (path and description), resolve full path -if help_matches=$(9p read agent/skills/help | grep -i "$keyword"); then - while IFS=$'\t' read -r skill desc; do - for intent in $(9p ls agent/skills | grep -v '^help$'); do - if 9p ls "agent/skills/$intent" | grep -qx "$skill"; then - results="${results:+$results -}skills/$intent/$skill $desc" - break - fi - done - done <<< "$help_matches" -fi - -# Search skill content for keyword -while IFS= read -r intent; do - [ "$intent" = "help" ] && continue - while IFS= read -r skill; do - path="skills/$intent/$skill" - echo "$results" | grep -q "^$path" && continue - if 9p read "agent/$path/SKILL.md" | grep -qi "$keyword"; then - desc=$(9p read agent/skills/help | grep "^${skill} " | cut -f2) - [ -n "$desc" ] && results="${results:+$results -}$path $desc" - fi - done < <(9p ls "agent/skills/$intent") +# Search each skill file for the keyword +while IFS= read -r file; do + [ "${file%.md}" = "$file" ] && continue # skip non-.md entries + name="${file%.md}" + content=$(9p read "agent/skills/$file") + if echo "$content" | grep -qi "$keyword"; then + desc=$(echo "$content" | grep -m1 '^description:' | sed 's/^description: *//') + results="${results:+$results +}$name $desc" + fi done < <(9p ls agent/skills) if [ -n "$results" ]; then diff --git a/mcptools/load_skill.sh b/mcptools/load_skill.sh new file mode 100755 index 0000000..2f60375 --- /dev/null +++ b/mcptools/load_skill.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# capabilities: discovery +# description: Load a skill's SKILL.md by name +set -euo pipefail + +# Usage: load_skill +# +# Accepts either a bare name or a path (the last component is used): +# anvillm-sessions +# skills/agents/anvillm-sessions (legacy path format; last component is used) + +if [ $# -ne 1 ]; then + echo "Usage: load_skill " >&2 + exit 1 +fi + +# Use the last path component as the skill name +name="${1##*/}" + +9p read "agent/skills/${name}.md" diff --git a/skills/anvillm-communication/SKILL.md b/skills/anvillm-communication/SKILL.md index b2209c3..1487ddb 100644 --- a/skills/anvillm-communication/SKILL.md +++ b/skills/anvillm-communication/SKILL.md @@ -14,7 +14,10 @@ when_to_load: Load when you need to send messages to other agents, respond to th Discover agents: ``` Tool: execute_code -code: bash <(9p read agent/tools/list_sessions.sh) | grep "$(pwd)" +pipe: [ + {"tool": "list_sessions.sh"}, + {"code": "grep ''"} +] ``` Send message: