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:
parent
3e74c264e9
commit
15bf980298
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Reference in New Issue