diff --git a/internal/p9/server.go b/internal/p9/server.go index 06fb6f2..a972fd6 100644 --- a/internal/p9/server.go +++ b/internal/p9/server.go @@ -450,17 +450,33 @@ func (s *Server) walk(cs *connState, fc *plan9.Fcall) *plan9.Fcall { newPath = "/beads/" + name } } else if path == "/tools" { - // Inside tools directory - if name == "anvilmcp" { - qid = plan9.Qid{Type: QTDir, Path: qidToolsBase + 1} - newPath = "/tools/anvilmcp" + // Inside tools directory - list capabilities + help + if s.tools != nil { + caps, _ := s.tools.listCapabilities() + // help file + qid = plan9.Qid{Type: QTFile, Path: qidToolsBase} + if name == "help" { + newPath = "/tools/help" + } else { + // Check if it's a capability + for _, cap := range caps { + if cap == name { + qid = plan9.Qid{Type: QTDir, Path: qidToolsBase + hashID(name)} + newPath = "/tools/" + name + break + } + } + if newPath == "" { + return errFcall(fc, "not found") + } + } } else { return errFcall(fc, "not found") } - } else if path == "/tools/anvilmcp" { - // Inside tools/anvilmcp - tool files - qid = plan9.Qid{Type: QTFile, Path: qidToolsBase + hashID(name)} - newPath = "/tools/anvilmcp/" + name + } else if strings.HasPrefix(path, "/tools/") && strings.Count(path, "/") == 2 { + // Inside tools/ - tool files + qid = plan9.Qid{Type: QTFile, Path: qidToolsBase + hashID(path+name)} + newPath = path + "/" + name } else if strings.HasPrefix(path, "/beads/") && strings.Count(path, "/") == 2 { // Inside a bead directory - property files beadID := strings.TrimPrefix(path, "/beads/") @@ -1024,17 +1040,30 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte { }) } } else if path == "/tools" { - dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTDir, Path: qidToolsBase}, - Mode: plan9.DMDIR | 0555, Name: "anvilmcp", Uid: "q", Gid: "q", Muid: "q", - }) - } else if path == "/tools/anvilmcp" { if s.tools != nil { - toolDirs, _ := s.tools.List("agent/tools/anvilmcp") - for _, td := range toolDirs { + // Add help file + dirs = append(dirs, plan9.Dir{ + Qid: plan9.Qid{Type: QTFile, Path: qidToolsBase}, + Mode: 0444, Name: "help", Uid: "q", Gid: "q", Muid: "q", + }) + // Add capability directories + caps, _ := s.tools.listCapabilities() + for _, cap := range caps { dirs = append(dirs, plan9.Dir{ - Qid: plan9.Qid{Type: QTFile, Path: qidToolsBase + hashID(td.Name)}, - Mode: 0444, Name: td.Name, Uid: "q", Gid: "q", Muid: "q", + Qid: plan9.Qid{Type: QTDir, Path: qidToolsBase + hashID(cap)}, + Mode: plan9.DMDIR | 0555, Name: cap, Uid: "q", Gid: "q", Muid: "q", + }) + } + } + } else if strings.HasPrefix(path, "/tools/") && strings.Count(path, "/") == 2 { + // Inside tools/ - list tools + capability := strings.TrimPrefix(path, "/tools/") + if s.tools != nil { + tools, _ := s.tools.listToolsInCapability(capability) + for _, tool := range tools { + dirs = append(dirs, plan9.Dir{ + Qid: plan9.Qid{Type: QTFile, Path: qidToolsBase + hashID(path+tool.Name)}, + Mode: 0444, Name: tool.Name, Uid: "q", Gid: "q", Muid: "q", }) } } diff --git a/internal/p9/tools.go b/internal/p9/tools.go index 852f7e4..224cb8f 100644 --- a/internal/p9/tools.go +++ b/internal/p9/tools.go @@ -1,15 +1,18 @@ package p9 import ( + "bufio" "fmt" "os" + "path/filepath" "strings" "9fans.net/go/plan9" ) type ToolsFS struct { - tools []Tool + tools []Tool + toolsDir string } type Tool struct { @@ -35,77 +38,229 @@ type Items struct { Type string `json:"type"` } +// ToolMeta holds parsed front-matter from a tool script +type ToolMeta struct { + Name string + Capabilities []string + Description string + Path string +} + func NewToolsFS(tools []Tool) *ToolsFS { - return &ToolsFS{tools: tools} + homeDir, _ := os.UserHomeDir() + return &ToolsFS{ + tools: tools, + toolsDir: filepath.Join(homeDir, ".config/anvillm/mcptools"), + } +} + +// parseFrontMatter extracts capabilities and description from script comments +func parseFrontMatter(path string) (*ToolMeta, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + meta := &ToolMeta{ + Name: filepath.Base(path), + Path: path, + } + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "#") { + break + } + line = strings.TrimPrefix(line, "#") + line = strings.TrimSpace(line) + + if strings.HasPrefix(line, "capabilities:") { + caps := strings.TrimPrefix(line, "capabilities:") + for _, c := range strings.Split(caps, ",") { + c = strings.TrimSpace(c) + if c != "" { + meta.Capabilities = append(meta.Capabilities, c) + } + } + } else if strings.HasPrefix(line, "description:") { + meta.Description = strings.TrimSpace(strings.TrimPrefix(line, "description:")) + } + } + return meta, nil +} + +// listAllTools scans toolsDir 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 + } + + 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())) + if err != nil { + continue + } + tools = append(tools, meta) + } + return tools, nil +} + +// listCapabilities returns unique capability names from all tools +func (t *ToolsFS) listCapabilities() ([]string, error) { + tools, err := t.listAllTools() + if err != nil { + return nil, err + } + + capSet := make(map[string]bool) + for _, tool := range tools { + for _, cap := range tool.Capabilities { + capSet[cap] = true + } + } + + var caps []string + for cap := range capSet { + caps = append(caps, cap) + } + return caps, nil +} + +// listToolsInCapability returns tools that have the given capability +func (t *ToolsFS) listToolsInCapability(capability string) ([]*ToolMeta, error) { + tools, err := t.listAllTools() + if err != nil { + return nil, err + } + + var result []*ToolMeta + for _, tool := range tools { + for _, cap := range tool.Capabilities { + if cap == capability { + result = append(result, tool) + break + } + } + } + return result, nil +} + +// generateHelp creates aggregated one-liner index of all tools +func (t *ToolsFS) generateHelp() (string, error) { + tools, err := t.listAllTools() + if err != nil { + return "", err + } + + var lines []string + for _, tool := range tools { + caps := strings.Join(tool.Capabilities, ",") + if caps == "" { + caps = "uncategorized" + } + line := fmt.Sprintf("%s/%s\t%s", caps, tool.Name, tool.Description) + lines = append(lines, line) + } + return strings.Join(lines, "\n") + "\n", nil } func (t *ToolsFS) List(path string) ([]plan9.Dir, error) { parts := strings.Split(strings.Trim(path, "/"), "/") - + + // /tools - list capabilities + help file if len(parts) == 2 && parts[0] == "agent" && parts[1] == "tools" { - return []plan9.Dir{ - {Name: "anvilmcp", Qid: plan9.Qid{Type: plan9.QTDIR}, Mode: plan9.DMDIR | 0555}, - }, nil - } - - if len(parts) == 3 && parts[0] == "agent" && parts[1] == "tools" && parts[2] == "anvilmcp" { - // Dynamically list .sh files from ~/.config/anvillm/mcptools/ - homeDir, err := os.UserHomeDir() + caps, err := t.listCapabilities() if err != nil { return nil, err } - - mcptoolsDir := fmt.Sprintf("%s/.config/anvillm/mcptools", homeDir) - entries, err := os.ReadDir(mcptoolsDir) - if err != nil { - return nil, err - } - + var dirs []plan9.Dir - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sh") { - continue - } + // Add help file + dirs = append(dirs, plan9.Dir{ + Name: "help", + Qid: plan9.Qid{Type: plan9.QTFILE}, + Mode: 0444, + }) + // Add capability directories + for _, cap := range caps { dirs = append(dirs, plan9.Dir{ - Name: entry.Name(), + Name: cap, + Qid: plan9.Qid{Type: plan9.QTDIR}, + Mode: plan9.DMDIR | 0555, + }) + } + return dirs, nil + } + + // /tools/ - list tools in that capability + if len(parts) == 3 && parts[0] == "agent" && parts[1] == "tools" { + capability := parts[2] + tools, err := t.listToolsInCapability(capability) + if err != nil { + return nil, err + } + + var dirs []plan9.Dir + for _, tool := range tools { + dirs = append(dirs, plan9.Dir{ + Name: tool.Name, Qid: plan9.Qid{Type: plan9.QTFILE}, Mode: 0555, }) } return dirs, nil } - + return nil, fmt.Errorf("not found") } func (t *ToolsFS) Read(path string) ([]byte, error) { parts := strings.Split(strings.Trim(path, "/"), "/") - - if len(parts) != 3 || parts[0] != "tools" || parts[1] != "anvilmcp" { + + // /tools/help - aggregated index + if len(parts) == 2 && parts[0] == "tools" && parts[1] == "help" { + help, err := t.generateHelp() + if err != nil { + return nil, err + } + return []byte(help), nil + } + + // /tools// - read tool script + if len(parts) != 3 || parts[0] != "tools" { return nil, fmt.Errorf("not found") } - + + capability := parts[1] filename := parts[2] + if !strings.HasSuffix(filename, ".sh") { return nil, fmt.Errorf("not a shell script") } - + // Prevent path traversal if strings.Contains(filename, "/") || strings.Contains(filename, "..") { return nil, fmt.Errorf("invalid filename") } - - // Read directly from disk - homeDir, err := os.UserHomeDir() + + // Verify tool has this capability + tools, err := t.listToolsInCapability(capability) if err != nil { return nil, err } - - scriptPath := fmt.Sprintf("%s/.config/anvillm/mcptools/%s", homeDir, filename) - content, err := os.ReadFile(scriptPath) - if err != nil { - return nil, fmt.Errorf("script not found") + + for _, tool := range tools { + if tool.Name == filename { + return os.ReadFile(tool.Path) + } } - - return content, nil + + return nil, fmt.Errorf("tool not found in capability") } diff --git a/scripts/update-tool-frontmatter.sh b/scripts/update-tool-frontmatter.sh new file mode 100644 index 0000000..bc34a95 --- /dev/null +++ b/scripts/update-tool-frontmatter.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Update tool scripts with front-matter for capability-based discovery +set -euo pipefail + +TOOLS_DIR="${HOME}/.config/anvillm/mcptools" + +update_tool() { + local file="$1" + local caps="$2" + local desc="$3" + + # Create temp file with new front-matter + { + echo "#!/bin/bash" + echo "# capabilities: $caps" + echo "# description: $desc" + # Skip old header lines, keep rest + tail -n +2 "$file" | grep -v "^# capabilities:" | grep -v "^# description:" | grep -v "^#.*Usage:" | grep -v "^# [a-z_]* -" + } > "${file}.tmp" + + mv "${file}.tmp" "$file" + chmod +x "$file" +} + +# send_message.sh +update_tool "$TOOLS_DIR/send_message.sh" "messaging" "Send message to agent or user: FROM TO TYPE SUBJECT BODY" + +# read_inbox.sh +update_tool "$TOOLS_DIR/read_inbox.sh" "messaging" "Read agent inbox: AGENT_ID" + +# list_sessions.sh +update_tool "$TOOLS_DIR/list_sessions.sh" "agents" "List all running agent sessions" + +# set_state.sh +update_tool "$TOOLS_DIR/set_state.sh" "agents" "Set agent state: AGENT_ID STATE" + +# list_skills.sh +update_tool "$TOOLS_DIR/list_skills.sh" "skills" "List available agent skills" + +# test.sh +update_tool "$TOOLS_DIR/test.sh" "testing" "Test tool for debugging" + +echo "Updated front-matter for all tools"