package main import ( "cmp" _ "embed" "html/template" "log" "net/http" "strings" "time" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/tool" "github.com/kami/maven/internal/webauthn" ) //go:embed tools.html var toolsHTML string // toolsTmpl — the enable surface. Server-rendered, no JS: a plain HTML form // POSTs back to /tools to enable a proposal. html/template escapes tool names + // utterances (they came from voice STT — untrusted text). var toolsTmpl = parsePage("tools", toolsHTML, template.FuncMap{ "join": strings.Join, "capability": func(t ipc.Tool) string { return tool.CapabilityOf(t).String() }, "risk": func(t ipc.Tool) string { return string(tool.RiskOf(t)) }, }) // handleTools serves the enable surface (GET) and applies an enable (POST). // POST fields: name, cmd (space-separated argv), destructive (checkbox). cmd is // whitespace-split — argv with embedded spaces isn't supported (ponytail: no // shell-word parsing; the box owner controls this input, quote a wrapper script // if an arg needs spaces). func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { if !requireCore(w, core, "tools") { return } ctx := r.Context() var msg string if r.Method == http.MethodPost { if !stepUpGate(w, session, requireStepUp) { return } action := r.FormValue("action") name := strings.TrimSpace(r.FormValue("name")) switch action { case "enable": scope := r.FormValue("scope") cmd := strings.Fields(r.FormValue("cmd")) destructive := r.FormValue("destructive") != "" if name == "" || len(cmd) == 0 { http.Error(w, "name and cmd required", http.StatusBadRequest) return } if err := core.EnableTool(ctx, name, cmd, destructive, scope, time.Now()); err != nil { log.Printf("tools: enable %q: %v", name, err) http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway) return } msg = "enabled " + name case "disable": if name == "" { http.Error(w, "name required", http.StatusBadRequest) return } if err := core.DisableTool(ctx, name); err != nil { log.Printf("tools: disable %q: %v", name, err) http.Error(w, "disable failed: "+err.Error(), http.StatusBadGateway) return } msg = "disabled " + name case "dismiss": if name == "" { http.Error(w, "name required", http.StatusBadRequest) return } if err := core.DeleteTool(ctx, name); err != nil { log.Printf("tools: dismiss %q: %v", name, err) http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway) return } msg = "dismissed " + name default: http.Error(w, "unknown action", http.StatusBadRequest) return } } proposed, err1 := core.ListTools(ctx, "proposed") enabled, err2 := core.ListTools(ctx, "enabled") if err := cmp.Or(err1, err2); err != nil { log.Printf("tools: %v", err) http.Error(w, "core read failed", http.StatusBadGateway) return } // MCP is off by default and an older core may not know the method at all, // so a failure here renders an empty section rather than breaking the page. servers, err := core.MCPServers(ctx) if err != nil { log.Printf("tools: mcp servers: %v", err) servers = nil } // Enabled rows are shown grouped by capability domain (Vikunja #452). A // flat list stops answering "what can she do to the house" somewhere // around fifteen rows, and that is the question this page exists for. renderPage(w, toolsTmpl, struct { Msg string Proposed []ipc.Tool Enabled []ipc.Tool Groups []tool.CapabilityGroup MCP []ipc.MCPServerStatus }{msg, proposed, enabled, tool.GroupByDomain(enabled), servers}) }