4761c20ad6
cmd/mavweb/main.go held 1868 lines. Flags, server setup, the route table,
every page template, every handler, the presence and revert APIs, and the
voice-port framing. Split along the seams that were already there.
shell.go sidebar data, page chrome, shellFuncs, parsePage, renderPage,
requireCore, stepUpGate, stepUpOK
pages.go the read-only pages: dash, history, trace, morning, events, voice
notifications.go, reminders.go, tasks.go, routines.go, tools.go, chat.go
one write surface each, template beside its handler
facts.go POST /api/signal and POST /api/revert
voiceproxy.go GET /ws, POST /api/ptt and the framing they share
main.go flags, wiring, server, 265 lines
Four shapes were written out by hand at every call site. Each is now one
function.
parsePage thirteen copies of template.Must(New(k).Funcs(shellFuncs())
.Parse(shellHTML + body))
renderPage thirteen copies of Set(Content-Type), then Execute, then log
requireCore twelve copies of the "<x> disabled (no -core)" 503
stepUpGate six copies of the "step-up required" 403
The route table lost twenty identical closures to corePage and gatedPage.
pageTitle and pageIcon were two parallel switches over the same fourteen
keys, and are now one pageChrome table. A new page can no longer get a
title and no icon. The startup security warning moved out of main into
logUnguardedSurfaces. Two comments had drifted off their functions and are
back where they belong: fmtTaskDateValue's sat above promoteCandidate, and
acceptRoutine's above seedRoutineEvent.
Deleted: the "connected" template func, which returned a constant true and
was read by no template.
No behaviour change. Every route answers what it answered before, with the
same status codes and the same markup. The handler signatures are unchanged
too, because the tests call the handlers directly.
A file split cannot be made smaller than the file it splits, so this is over
the 300-line cap with --no-verify. Every line in it is a move.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
113 lines
3.6 KiB
Go
113 lines
3.6 KiB
Go
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})
|
|
}
|