p9/skills: flatten to <name>.md, add load_skill.sh, fix discover_skill.sh

Replace intent-based hierarchy (skills/<intent>/<name>/) with flat
skills/<name>.md files serving SKILL.md content directly. Update
discover_skill.sh to search the flat layout. Add load_skill.sh mcp
tool. Update anvillm-communication SKILL.md to use pipe syntax.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-03-06 19:04:58 +01:00
parent 3e74c264e9
commit 15bf980298
5 changed files with 79 additions and 260 deletions

View File

@ -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: <name>.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/<intent> - 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/<intent>/<skill> - 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/<intent> - list skill directories
intent := strings.TrimPrefix(path, "/skills/")
if s.skills != nil {
skills, _ := s.skills.listSkillsInIntent(intent)
// Flat: list <name>.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/<intent>/<skill> - 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",
})
}
}

View File

@ -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 <name>.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 → <name>.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/<intent> - 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/<intent>/<skillname> - 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/<name>.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/<name>.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/<intent>/<skillname>/<file>
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")
}

View File

@ -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

20
mcptools/load_skill.sh Executable file
View File

@ -0,0 +1,20 @@
#!/bin/bash
# capabilities: discovery
# description: Load a skill's SKILL.md by name
set -euo pipefail
# Usage: load_skill <skill-name>
#
# 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 <skill-name>" >&2
exit 1
fi
# Use the last path component as the skill name
name="${1##*/}"
9p read "agent/skills/${name}.md"

View File

@ -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 '<current-working-dir>'"}
]
```
Send message: