From 485016127d736a08dc3d33892d81de46946c87c4 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 4 Mar 2026 21:15:23 +0100 Subject: [PATCH] Add roles 9P endpoint to server and support multiple tool directories - Wire up roles filesystem in server.go - Add /agent/roles directory with focus areas and help - Support ANVILLM_TOOLS_DIR env var for multiple tool directories - Update pcloudcc-testing skill documentation --- internal/p9/server.go | 71 +++++++++++++++++++++++++++++++ internal/p9/tools.go | 73 +++++++++++++++++++++++--------- mcptools/list_roles.sh | 22 ++++++++++ skills/pcloudcc-testing/SKILL.md | 2 +- 4 files changed, 147 insertions(+), 21 deletions(-) create mode 100755 mcptools/list_roles.sh diff --git a/internal/p9/server.go b/internal/p9/server.go index ad63782..a3b82c2 100644 --- a/internal/p9/server.go +++ b/internal/p9/server.go @@ -85,6 +85,7 @@ const ( qidBeadsReady // beads/ready qidTools // tools directory qidSkills // skills directory + qidRoles // roles directory qidSessionBase = 1000 qidPeersBase = 0x10000000 // peers/{id}/file qidInboxBase = 0x20000000 // session/{id}/inbox @@ -94,6 +95,7 @@ const ( qidBeadsBase = 0x60000000 // beads/{id}/file qidToolsBase = 0x70000000 // tools/{capability}/{tool} qidSkillsBase = 0x80000000 // skills/{intent}/{skill} + qidRolesBase = 0x90000000 // roles/{focus-area}/{role} ) // File indices within a session directory @@ -127,6 +129,7 @@ type Server struct { beads *BeadsFS tools *ToolsFS skills *SkillsFS + roles *RolesFS OnAliasChange func(backend.Session) // Called when session alias changes mu sync.RWMutex } @@ -183,6 +186,7 @@ func NewServer(mgr *session.Manager, beadsFS *BeadsFS) (*Server, error) { beads: beadsFS, tools: toolsFS, skills: NewSkillsFS(), + roles: NewRolesFS(), } go s.acceptLoop() return s, nil @@ -413,6 +417,9 @@ func (s *Server) walk(cs *connState, fc *plan9.Fcall) *plan9.Fcall { case "skills": qid = plan9.Qid{Type: QTDir, Path: qidSkills} newPath = "/skills" + case "roles": + qid = plan9.Qid{Type: QTDir, Path: qidRoles} + newPath = "/roles" default: // Check if it's a session ID if sess := s.mgr.Get(name); sess != nil { @@ -517,6 +524,26 @@ func (s *Server) walk(cs *connState, fc *plan9.Fcall) *plan9.Fcall { // 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 + help + if s.roles != nil { + focusAreas, _ := s.roles.listFocusAreas() + qid = plan9.Qid{Type: QTFile, Path: qidRolesBase} + if name == "help" { + newPath = "/roles/help" + } else if slices.Contains(focusAreas, name) { + qid = plan9.Qid{Type: QTDir, Path: qidRolesBase + hashID(name)} + newPath = "/roles/" + name + } else { + return errFcall(fc, "not found") + } + } else { + return errFcall(fc, "not found") + } + } else if strings.HasPrefix(path, "/roles/") && strings.Count(path, "/") == 2 { + // Inside roles/ - role files + qid = plan9.Qid{Type: QTFile, Path: qidRolesBase + hashID(path+name)} + newPath = path + "/" + name } else if strings.HasPrefix(path, "/beads/") && strings.Count(path, "/") == 2 { // Inside a mount directory mountName := strings.TrimPrefix(path, "/beads/") @@ -1043,6 +1070,10 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte { Qid: plan9.Qid{Type: QTDir, Path: qidSkills}, Mode: plan9.DMDIR | 0555, Name: "skills", Uid: "q", Gid: "q", Muid: "q", }) + dirs = append(dirs, plan9.Dir{ + Qid: plan9.Qid{Type: QTDir, Path: qidRoles}, + Mode: plan9.DMDIR | 0555, Name: "roles", Uid: "q", Gid: "q", Muid: "q", + }) for _, id := range s.mgr.List() { dirs = append(dirs, plan9.Dir{ Qid: plan9.Qid{Type: QTDir, Path: qidSessionBase + hashID(id)}, @@ -1116,6 +1147,34 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte { }) } } + } else if path == "/roles" { + if s.roles != nil { + // Add help file + dirs = append(dirs, plan9.Dir{ + Qid: plan9.Qid{Type: QTFile, Path: qidRolesBase}, + Mode: 0444, Name: "help", Uid: "q", Gid: "q", Muid: "q", + }) + // Add focus area directories + focusAreas, _ := s.roles.listFocusAreas() + for _, fa := range focusAreas { + dirs = append(dirs, plan9.Dir{ + Qid: plan9.Qid{Type: QTDir, Path: qidRolesBase + hashID(fa)}, + Mode: plan9.DMDIR | 0555, Name: fa, Uid: "q", Gid: "q", Muid: "q", + }) + } + } + } else if strings.HasPrefix(path, "/roles/") && strings.Count(path, "/") == 2 { + // Inside roles/ - list role files + focusArea := strings.TrimPrefix(path, "/roles/") + if s.roles != nil { + roles, _ := s.roles.listRolesInFocusArea(focusArea) + for _, role := range roles { + dirs = append(dirs, plan9.Dir{ + Qid: plan9.Qid{Type: QTFile, Path: qidRolesBase + hashID(path+role.Name)}, + Mode: 0444, Name: role.Name + ".md", Uid: "q", Gid: "q", Muid: "q", + }) + } + } } else if path == "/beads" { // Beads directory - only ctl, mtab, ready (aggregate), and mounted projects dirs = append(dirs, plan9.Dir{ @@ -1400,6 +1459,18 @@ func (s *Server) readFile(path string) string { return "" } + // Handle roles paths + if strings.HasPrefix(path, "/roles/") { + if s.roles != nil { + data, err := s.roles.Read("agent/" + strings.TrimPrefix(path, "/")) + if err != nil { + return "" + } + return string(data) + } + return "" + } + // Handle beads paths if beadsPath, ok := strings.CutPrefix(path, "/beads/"); ok { if s.beads != nil { diff --git a/internal/p9/tools.go b/internal/p9/tools.go index 9bc30e3..0a47369 100644 --- a/internal/p9/tools.go +++ b/internal/p9/tools.go @@ -15,8 +15,8 @@ import ( // ToolsFS provides 9P filesystem access to agent tools organized by capability level. // Tools are shell scripts with YAML front-matter defining their interface. type ToolsFS struct { - tools []Tool - toolsDir string + tools []Tool + toolsDirs []string } // Tool represents an agent tool with its MCP-compatible schema. @@ -55,10 +55,28 @@ type ToolMeta struct { } func NewToolsFS(tools []Tool) *ToolsFS { - homeDir, _ := os.UserHomeDir() + var dirs []string + + // Check ANVILLM_TOOLS_DIR first (colon-separated) + if envDirs := os.Getenv("ANVILLM_TOOLS_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, "mcptools")) + } + defaults = append(defaults, + filepath.Join(homeDir, ".kiro/mcptools"), + filepath.Join(homeDir, ".config/anvillm/mcptools"), + ) + dirs = defaults + } + return &ToolsFS{ - tools: tools, - toolsDir: filepath.Join(homeDir, ".config/anvillm/mcptools"), + tools: tools, + toolsDirs: dirs, } } @@ -98,23 +116,31 @@ func parseFrontMatter(path string) (*ToolMeta, error) { return meta, nil } -// listAllTools scans toolsDir and returns metadata for all .sh files +// listAllTools scans all tools directories and returns metadata for all .sh files func (t *ToolsFS) listAllTools() ([]*ToolMeta, error) { - entries, err := os.ReadDir(t.toolsDir) - if err != nil { - return nil, err - } - + seen := make(map[string]bool) var tools []*ToolMeta - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sh") { - continue - } - meta, err := parseFrontMatter(filepath.Join(t.toolsDir, entry.Name())) + + for _, dir := range t.toolsDirs { + entries, err := os.ReadDir(dir) if err != nil { continue } - tools = append(tools, meta) + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sh") { + continue + } + if seen[entry.Name()] { + continue + } + meta, err := parseFrontMatter(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + seen[entry.Name()] = true + tools = append(tools, meta) + } } return tools, nil } @@ -266,9 +292,16 @@ func (t *ToolsFS) Read(path string) ([]byte, error) { for _, tool := range tools { if tool.Name == filename { - // Verify path stays within toolsDir - relPath, err := filepath.Rel(t.toolsDir, tool.Path) - if err != nil || strings.HasPrefix(relPath, "..") { + // Verify path stays within one of the tools directories + valid := false + for _, dir := range t.toolsDirs { + relPath, err := filepath.Rel(dir, tool.Path) + if err == nil && !strings.HasPrefix(relPath, "..") { + valid = true + break + } + } + if !valid { return nil, fmt.Errorf("invalid tool path") } return os.ReadFile(tool.Path) diff --git a/mcptools/list_roles.sh b/mcptools/list_roles.sh new file mode 100755 index 0000000..58e2c02 --- /dev/null +++ b/mcptools/list_roles.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# capabilities: roles +# description: List all available roles (JSON array) +set -euo pipefail + +# Verify running under landrun (test filesystem restriction) +if cat /etc/shadow >/dev/null 2>&1; then + echo "Error: This script must be run via execute_code tool" >&2 + exit 1 +fi + +9p ls agent/roles 2>/dev/null | grep -v '^help$' | while read focus_area; do + 9p ls "agent/roles/$focus_area" 2>/dev/null | while read role_file; do + role_name="${role_file%.md}" + desc=$(9p read "agent/roles/$focus_area/$role_file" 2>/dev/null | awk '/^---$/,/^---$/ {if (/^description:/) {sub(/^description: */, ""); print; exit}}') + printf '%s\t%s\t%s\n' "$focus_area" "$role_name" "$desc" + done +done | jq -Rs ' + split("\n") | map(select(length > 0)) | map( + split("\t") | {focus_area: .[0], name: .[1], description: .[2]} + ) +' diff --git a/skills/pcloudcc-testing/SKILL.md b/skills/pcloudcc-testing/SKILL.md index cd4e5f4..1b5ac63 100644 --- a/skills/pcloudcc-testing/SKILL.md +++ b/skills/pcloudcc-testing/SKILL.md @@ -45,7 +45,7 @@ Load pcloudcc-testing skill. Test the affected code path with unit tests, fault **Kill testing agent**: `bash <(9p read agent/tools/agents/kill_agent.sh) ` -**Note**: Use `execute_bash`, not `execute_code` +**Note**: `spawn_agent.sh` is an exception — use the Bash tool (`execute_bash`), NOT `execute_code`. All other commands in this section use `execute_code` as normal. ### Testing Agent Workflow