Use physical filenames in roles 9P endpoint

- Add DisplayName field to RoleMeta for frontmatter name
- Name field now always contains physical filename (never overwritten)
- Use physical filename for 9P paths and deduplication
- Use DisplayName in help output for discovery
- Add unique filtering (sort -u) to discover_role/skill/tool scripts
This commit is contained in:
Levi Neely 2026-03-04 21:14:42 +01:00
parent d0e3ff2cd8
commit 65e5081134
4 changed files with 338 additions and 2 deletions

320
internal/p9/roles.go Normal file
View File

@ -0,0 +1,320 @@
package p9
import (
"bufio"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"9fans.net/go/plan9"
)
// RolesFS provides 9P filesystem access to agent roles organized by focus areas.
// Roles are markdown files with YAML front-matter defining specialized agent personas.
type RolesFS struct {
rolesDirs []string
}
// RoleMeta holds parsed YAML front-matter from role markdown files.
type RoleMeta struct {
Name string // Physical filename (without .md)
DisplayName string // Name from frontmatter (for discovery)
FocusAreas []string
Description string
Path string // file path
}
// NewRolesFS creates a new roles filesystem handler.
// It discovers roles from ANVILLM_ROLES_DIR environment variable or default locations.
func NewRolesFS() *RolesFS {
var dirs []string
// Check ANVILLM_ROLES_DIR first (colon-separated)
if envDirs := os.Getenv("ANVILLM_ROLES_DIR"); envDirs != "" {
dirs = strings.Split(envDirs, ":")
} else {
// Default: Claude, Kiro, AnviLLM config directories
homeDir, _ := os.UserHomeDir()
var defaults []string
if claudeDir := os.Getenv("CLAUDE_CONFIG_DIR"); claudeDir != "" {
defaults = append(defaults, filepath.Join(claudeDir, "roles"))
}
defaults = append(defaults,
filepath.Join(homeDir, ".kiro/roles"),
filepath.Join(homeDir, ".config/anvillm/roles"),
)
dirs = defaults
}
return &RolesFS{rolesDirs: dirs}
}
// parseRoleFrontMatter extracts focus-areas and description from role markdown YAML front-matter
func parseRoleFrontMatter(rolePath string, derivedFocusAreas []string) (*RoleMeta, error) {
f, err := os.Open(rolePath)
if err != nil {
return nil, err
}
defer f.Close()
meta := &RoleMeta{
Name: strings.TrimSuffix(filepath.Base(rolePath), ".md"),
Path: rolePath,
FocusAreas: derivedFocusAreas,
}
scanner := bufio.NewScanner(f)
inFrontMatter := false
hasFrontMatterFocusAreas := false
for scanner.Scan() {
line := scanner.Text()
if line == "---" {
if !inFrontMatter {
inFrontMatter = true
continue
} else {
break // end of front-matter
}
}
if !inFrontMatter {
continue
}
if focusAreas, ok := strings.CutPrefix(line, "focus-areas:"); ok {
hasFrontMatterFocusAreas = true
for fa := range strings.SplitSeq(focusAreas, ",") {
fa = strings.TrimSpace(fa)
if fa != "" && !slices.Contains(meta.FocusAreas, fa) {
meta.FocusAreas = append(meta.FocusAreas, fa)
}
}
} else if desc, ok := strings.CutPrefix(line, "description:"); ok {
meta.Description = strings.TrimSpace(desc)
} else if name, ok := strings.CutPrefix(line, "name:"); ok {
meta.DisplayName = strings.TrimSpace(name)
}
}
// If no frontmatter focus-areas and no derived, add "uncategorized"
if !hasFrontMatterFocusAreas && len(derivedFocusAreas) == 0 {
meta.FocusAreas = []string{"uncategorized"}
}
return meta, nil
}
// scanRolesDir recursively scans a directory for role markdown files
func (r *RolesFS) scanRolesDir(baseDir string, currentPath string, derivedFocusAreas []string) ([]*RoleMeta, error) {
var roles []*RoleMeta
entries, err := os.ReadDir(currentPath)
if err != nil {
return nil, err
}
for _, entry := range entries {
fullPath := filepath.Join(currentPath, entry.Name())
if entry.IsDir() {
// Derive focus area from directory structure
newDerived := append([]string{}, derivedFocusAreas...)
newDerived = append(newDerived, entry.Name())
subRoles, err := r.scanRolesDir(baseDir, fullPath, newDerived)
if err != nil {
continue
}
roles = append(roles, subRoles...)
} else if strings.HasSuffix(entry.Name(), ".md") {
meta, err := parseRoleFrontMatter(fullPath, derivedFocusAreas)
if err != nil {
continue
}
roles = append(roles, meta)
}
}
return roles, nil
}
// listAllRoles scans all roles directories and returns metadata
func (r *RolesFS) listAllRoles() ([]*RoleMeta, error) {
seen := make(map[string]*RoleMeta)
for _, dir := range r.rolesDirs {
roles, err := r.scanRolesDir(dir, dir, nil)
if err != nil {
continue
}
for _, role := range roles {
// Use physical filename (role.Name) as unique key
// Merge: keep first occurrence, union focus areas
if existing, exists := seen[role.Name]; exists {
for _, fa := range role.FocusAreas {
if !slices.Contains(existing.FocusAreas, fa) {
existing.FocusAreas = append(existing.FocusAreas, fa)
}
}
} else {
seen[role.Name] = role
}
}
}
var result []*RoleMeta
for _, role := range seen {
result = append(result, role)
}
return result, nil
}
// listFocusAreas returns unique focus area names from all roles
func (r *RolesFS) listFocusAreas() ([]string, error) {
roles, err := r.listAllRoles()
if err != nil {
return nil, err
}
focusSet := make(map[string]bool)
for _, role := range roles {
for _, fa := range role.FocusAreas {
focusSet[fa] = true
}
}
var focusAreas []string
for fa := range focusSet {
focusAreas = append(focusAreas, fa)
}
return focusAreas, nil
}
// listRolesInFocusArea returns roles that have the given focus area
func (r *RolesFS) listRolesInFocusArea(focusArea string) ([]*RoleMeta, error) {
roles, err := r.listAllRoles()
if err != nil {
return nil, err
}
var result []*RoleMeta
for _, role := range roles {
if slices.Contains(role.FocusAreas, focusArea) {
result = append(result, role)
}
}
return result, nil
}
// generateHelp creates aggregated index: focus-area/role-name\tdescription
func (r *RolesFS) generateHelp() (string, error) {
roles, err := r.listAllRoles()
if err != nil {
return "", err
}
var lines []string
for _, role := range roles {
displayName := role.DisplayName
if displayName == "" {
displayName = role.Name
}
for _, fa := range role.FocusAreas {
line := fmt.Sprintf("%s/%s\t%s", fa, displayName, role.Description)
lines = append(lines, line)
}
}
return strings.Join(lines, "\n") + "\n", nil
}
// List returns directory entries for a roles path
func (r *RolesFS) List(path string) ([]plan9.Dir, error) {
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
// /agent/roles - list focus areas + help file
if len(parts) == 2 && parts[0] == "agent" && parts[1] == "roles" {
focusAreas, err := r.listFocusAreas()
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 _, fa := range focusAreas {
dirs = append(dirs, plan9.Dir{
Name: fa,
Qid: plan9.Qid{Type: plan9.QTDIR},
Mode: plan9.DMDIR | 0555,
})
}
return dirs, nil
}
// /agent/roles/<focus-area> - list role files
if len(parts) == 3 && parts[0] == "agent" && parts[1] == "roles" {
focusArea := parts[2]
roles, err := r.listRolesInFocusArea(focusArea)
if err != nil {
return nil, err
}
var dirs []plan9.Dir
for _, role := range roles {
dirs = append(dirs, plan9.Dir{
Name: role.Name + ".md",
Qid: plan9.Qid{Type: plan9.QTFILE},
Mode: 0444,
})
}
return dirs, nil
}
return nil, fmt.Errorf("not found")
}
// Read returns file content for a roles path
func (r *RolesFS) Read(path string) ([]byte, error) {
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
// /agent/roles/help
if len(parts) == 3 && parts[0] == "agent" && parts[1] == "roles" && parts[2] == "help" {
help, err := r.generateHelp()
if err != nil {
return nil, err
}
return []byte(help), nil
}
// /agent/roles/<focus-area>/<role-name>.md
if len(parts) != 4 || parts[0] != "agent" || parts[1] != "roles" {
return nil, fmt.Errorf("not found")
}
focusArea := parts[2]
fileName := parts[3]
if !strings.HasSuffix(fileName, ".md") {
return nil, fmt.Errorf("not a markdown file")
}
roles, err := r.listRolesInFocusArea(focusArea)
if err != nil {
return nil, err
}
for _, role := range roles {
if role.Name == strings.TrimSuffix(fileName, ".md") {
return os.ReadFile(role.Path)
}
}
return nil, fmt.Errorf("role not found")
}

16
mcptools/discover_role.sh Executable file
View File

@ -0,0 +1,16 @@
#!/bin/bash
# capabilities: mcp
# description: Discover available roles by focus area and keyword
set -euo pipefail
# Usage: discover_role <keyword>
# Returns: focus-area/role-name pairs matching keyword
if [ $# -lt 1 ]; then
echo "Usage: discover_role <keyword>" >&2
exit 1
fi
keyword="$1"
9p read agent/roles/help 2>/dev/null | grep -i "$keyword" | sort -u || echo "No roles found matching: $keyword"

View File

@ -13,4 +13,4 @@ fi
keyword="$1"
9p read agent/skills/help 2>/dev/null | grep -i "$keyword" || echo "No skills found matching: $keyword"
9p read agent/skills/help 2>/dev/null | grep -i "$keyword" | sort -u || echo "No skills found matching: $keyword"

View File

@ -13,4 +13,4 @@ fi
keyword="$1"
9p read agent/tools/help 2>/dev/null | grep -i "$keyword" || echo "No tools found matching: $keyword"
9p read agent/tools/help 2>/dev/null | grep -i "$keyword" | sort -u || echo "No tools found matching: $keyword"