From 65e5081134a3e725897a0913bd28be2627bf2af4 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 4 Mar 2026 21:14:42 +0100 Subject: [PATCH] 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 --- internal/p9/roles.go | 320 +++++++++++++++++++++++++++++++++++++ mcptools/discover_role.sh | 16 ++ mcptools/discover_skill.sh | 2 +- mcptools/discover_tool.sh | 2 +- 4 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 internal/p9/roles.go create mode 100755 mcptools/discover_role.sh diff --git a/internal/p9/roles.go b/internal/p9/roles.go new file mode 100644 index 0000000..ae2ccd1 --- /dev/null +++ b/internal/p9/roles.go @@ -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/ - 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//.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") +} diff --git a/mcptools/discover_role.sh b/mcptools/discover_role.sh new file mode 100755 index 0000000..773b5ed --- /dev/null +++ b/mcptools/discover_role.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# capabilities: mcp +# description: Discover available roles by focus area and keyword +set -euo pipefail + +# Usage: discover_role +# Returns: focus-area/role-name pairs matching keyword + +if [ $# -lt 1 ]; then + echo "Usage: discover_role " >&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" diff --git a/mcptools/discover_skill.sh b/mcptools/discover_skill.sh index d420e25..e1b88b6 100755 --- a/mcptools/discover_skill.sh +++ b/mcptools/discover_skill.sh @@ -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" diff --git a/mcptools/discover_tool.sh b/mcptools/discover_tool.sh index 2129be6..3d267ed 100755 --- a/mcptools/discover_tool.sh +++ b/mcptools/discover_tool.sh @@ -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"