From a970ec1392c03115f5e7aac4631153b08f23db3e Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Sat, 2 May 2026 11:00:03 -0400 Subject: [PATCH 01/65] =?UTF-8?q?Add=20`factorly=20ui`=20=E2=80=94=20local?= =?UTF-8?q?host=20web=20UI=20for=20tool=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a browser-based UI for configuring, testing, and browsing tools. Starts a local HTTP server and opens the browser — like Postman for governed tool orchestration. Command: - `factorly ui` starts server on localhost:3741, opens browser - `--port` flag to customize port - Cross-platform browser open (open/xdg-open/rundll32) Architecture: - Go stdlib net/http with ServeMux pattern matching (Go 1.22+) - html/template with per-page parsing to avoid {{define "content"}} conflicts — each page template is cloned with the shared layout - htmx 2.x vendored locally (50KB) for dynamic interactions - htmx SSE extension vendored locally (9KB) for future streaming - Tailwind CSS via CDN (only external dependency) - All assets embedded via //go:embed — single binary, no file deps - Imports proxy/registry/config directly (same process, no API) - fs.Sub used to strip embed directory prefix for static serving Pages: - /tools — list all configured tools with type, description, and oversight rules (shadow summary badges) - /tools/{name} — tool editor showing type-specific config (CLI: command/args, REST: method/url/path, MCP: command/url, workflow: step list), parameters form, oversight summary - /tools/{name}/try — POST endpoint that executes the tool through the full proxy (parameter validation, shadow policy, logging, output filtering) and returns HTML result inline via htmx - /templates — grid of 39 templates grouped by category with auth type badges - /templates/{name} — detail view with tools included, auth guide, vault key, and full YAML preview - /vault — placeholder page (coming soon) Try It flow: - User fills parameter form, clicks Run - htmx POSTs form data, shows "running..." indicator - Server calls proxy.ExecuteWithContext with interface "ui" - Returns color-coded HTML result: green for success, red for blocked/denied, amber for tool errors - Output HTML-escaped to prevent XSS from tool results - Duration shown in milliseconds Template functions: - shadowSummary: renders ShadowConfig as concise badge text - templateCategories: extracts unique categories from templates - inc: increments integer for step numbering Files: - cmd/factorly/ui_cmd.go — cobra command + browser open - internal/ui/server.go — Server struct, routes, template parsing - internal/ui/handlers_tools.go — tools list, editor, try-it, render - internal/ui/handlers_templates.go — template browser + detail - internal/ui/handlers_vault.go — vault placeholder - internal/ui/embed.go — //go:embed directives - internal/ui/sse.go — SSE writer (for future workflow streaming) - internal/ui/static/ — htmx.min.js, sse.js, style.css - internal/ui/templates/ — 7 HTML templates + partials dir --- src/cmd/factorly/main.go | 2 +- src/cmd/factorly/ui_cmd.go | 72 +++++ src/internal/ui/embed.go | 12 + src/internal/ui/handlers_templates.go | 35 +++ src/internal/ui/handlers_tools.go | 132 ++++++++ src/internal/ui/handlers_vault.go | 13 + src/internal/ui/server.go | 152 +++++++++ src/internal/ui/sse.go | 41 +++ src/internal/ui/static/htmx.min.js | 1 + src/internal/ui/static/sse.js | 290 ++++++++++++++++++ src/internal/ui/static/style.css | 2 + src/internal/ui/templates/layout.html | 27 ++ src/internal/ui/templates/partials/empty.html | 1 + .../ui/templates/template_detail.html | 43 +++ src/internal/ui/templates/templates.html | 17 + src/internal/ui/templates/tool_edit.html | 99 ++++++ src/internal/ui/templates/tools.html | 33 ++ src/internal/ui/templates/vault.html | 4 + 18 files changed, 975 insertions(+), 1 deletion(-) create mode 100644 src/cmd/factorly/ui_cmd.go create mode 100644 src/internal/ui/embed.go create mode 100644 src/internal/ui/handlers_templates.go create mode 100644 src/internal/ui/handlers_tools.go create mode 100644 src/internal/ui/handlers_vault.go create mode 100644 src/internal/ui/server.go create mode 100644 src/internal/ui/sse.go create mode 100644 src/internal/ui/static/htmx.min.js create mode 100644 src/internal/ui/static/sse.js create mode 100644 src/internal/ui/static/style.css create mode 100644 src/internal/ui/templates/layout.html create mode 100644 src/internal/ui/templates/partials/empty.html create mode 100644 src/internal/ui/templates/template_detail.html create mode 100644 src/internal/ui/templates/templates.html create mode 100644 src/internal/ui/templates/tool_edit.html create mode 100644 src/internal/ui/templates/tools.html create mode 100644 src/internal/ui/templates/vault.html diff --git a/src/cmd/factorly/main.go b/src/cmd/factorly/main.go index 18b45cd..ec97dc8 100644 --- a/src/cmd/factorly/main.go +++ b/src/cmd/factorly/main.go @@ -480,7 +480,7 @@ func init() { toolsCmd.AddCommand(toolsListCmd, addCmd, removeCmd, importCmd, recordCmd, statusCmd) utilsCmd.AddCommand(autocompleteCmd) - rootCmd.AddCommand(versionCmd, toolsCmd, callCmd, initCmd, syncCmd, vaultCmd, authCmd, serveCmd, wrapCmd, execCmd, logsCmd, utilsCmd) + rootCmd.AddCommand(versionCmd, toolsCmd, callCmd, initCmd, syncCmd, vaultCmd, authCmd, serveCmd, wrapCmd, execCmd, logsCmd, utilsCmd, uiCmd) } // loadConfig loads config and builds a registry. Does not open the vault diff --git a/src/cmd/factorly/ui_cmd.go b/src/cmd/factorly/ui_cmd.go new file mode 100644 index 0000000..17e9f75 --- /dev/null +++ b/src/cmd/factorly/ui_cmd.go @@ -0,0 +1,72 @@ +// Copyright 2026 Jordan Sherer +// SPDX-License-Identifier: gpl + +package main + +import ( + "fmt" + "os/exec" + "runtime" + + "github.com/factorly-dev/factorly/internal/ui" + "github.com/spf13/cobra" +) + +var uiPort int + +var uiCmd = &cobra.Command{ + Use: "ui", + Short: "Open the Factorly web UI", + Long: "Starts a localhost web server with a visual interface for configuring tools, running them, and managing workflows.", + RunE: runUI, +} + +func init() { + uiCmd.Flags().IntVar(&uiPort, "port", 3741, "port for the UI server") +} + +func runUI(cmd *cobra.Command, args []string) error { + cfg, reg, err := loadConfig() + if err != nil { + return err + } + + p, err := bootstrapProviders(cfg, reg) + if err != nil { + return err + } + defer p.Teardown() + + addr := fmt.Sprintf("localhost:%d", uiPort) + + srv, err := ui.New(ui.Options{ + Config: cfg, + CfgPath: configPath, + ToolsDir: cfg.ToolsDir, + Registry: reg, + Proxy: p, + }) + if err != nil { + return err + } + + // Open browser + go openBrowser(fmt.Sprintf("http://%s", addr)) + + return srv.Start(addr) +} + +func openBrowser(url string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + } + if cmd != nil { + _ = cmd.Start() + } +} diff --git a/src/internal/ui/embed.go b/src/internal/ui/embed.go new file mode 100644 index 0000000..be52980 --- /dev/null +++ b/src/internal/ui/embed.go @@ -0,0 +1,12 @@ +// Copyright 2026 Jordan Sherer +// SPDX-License-Identifier: gpl + +package ui + +import "embed" + +//go:embed static/* +var staticFS embed.FS + +//go:embed templates/*.html templates/partials/*.html +var templatesFS embed.FS diff --git a/src/internal/ui/handlers_templates.go b/src/internal/ui/handlers_templates.go new file mode 100644 index 0000000..eaf85ef --- /dev/null +++ b/src/internal/ui/handlers_templates.go @@ -0,0 +1,35 @@ +// Copyright 2026 Jordan Sherer +// SPDX-License-Identifier: gpl + +package ui + +import ( + "net/http" + + "github.com/factorly-dev/factorly/internal/templates" +) + +func (s *Server) handleTemplatesList(w http.ResponseWriter, r *http.Request) { + s.render(w, "templates.html", map[string]any{ + "Title": "Templates", + "Nav": "templates", + "Templates": templates.All(), + }) +} + +func (s *Server) handleTemplateDetail(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + tmpl := templates.Get(name) + if tmpl == nil { + http.NotFound(w, r) + return + } + + s.render(w, "template_detail.html", map[string]any{ + "Title": tmpl.DisplayName, + "Nav": "templates", + "Template": tmpl, + "ToolNames": tmpl.ToolNames(), + "YAML": tmpl.YAML, + }) +} diff --git a/src/internal/ui/handlers_tools.go b/src/internal/ui/handlers_tools.go new file mode 100644 index 0000000..2d47463 --- /dev/null +++ b/src/internal/ui/handlers_tools.go @@ -0,0 +1,132 @@ +// Copyright 2026 Jordan Sherer +// SPDX-License-Identifier: gpl + +package ui + +import ( + "context" + "fmt" + "html/template" + "net/http" + "sort" + "time" + + "github.com/factorly-dev/factorly/internal/config" +) + +type toolListItem struct { + Name string + Type string + Description string + Shadow *config.ShadowConfig +} + +func (s *Server) handleToolsList(w http.ResponseWriter, r *http.Request) { + var tools []toolListItem + for name, tc := range s.cfg.Tools { + tools = append(tools, toolListItem{ + Name: name, + Type: tc.Type, + Description: tc.Description, + Shadow: tc.Shadow, + }) + } + sort.Slice(tools, func(i, j int) bool { return tools[i].Name < tools[j].Name }) + + s.render(w, "tools.html", map[string]any{ + "Title": "Tools", + "Nav": "tools", + "Tools": tools, + }) +} + +func (s *Server) handleToolEdit(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + tc, ok := s.cfg.Tools[name] + if !ok { + http.NotFound(w, r) + return + } + + // Combine tool config name with struct for template + type toolView struct { + Name string + config.ToolConfig + } + + s.render(w, "tool_edit.html", map[string]any{ + "Title": name, + "Nav": "tools", + "Tool": toolView{Name: name, ToolConfig: tc}, + "Params": tc.Parameters, + }) +} + +func (s *Server) handleToolTry(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Extract params from form (prefixed with "param_") + params := make(map[string]string) + for key, vals := range r.Form { + if len(key) > 6 && key[:6] == "param_" { + params[key[6:]] = vals[0] + } + } + + start := time.Now() + ctx := context.Background() + result, execErr := s.proxy.ExecuteWithContext(ctx, name, params, "ui") + duration := time.Since(start) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + if execErr != nil { + fmt.Fprintf(w, `
+
+ Blocked + %dms +
+
%s
+
`, duration.Milliseconds(), execErr.Error()) + return + } + + output := "" + status := "success" + if result != nil { + output = result.Output + if result.IsError() { + status = "error" + } + } + + statusColor := "green" + if status == "error" { + statusColor = "amber" + } + + fmt.Fprintf(w, `
+
+ %s + %dms +
+
%s
+
`, statusColor, statusColor, statusColor, status, duration.Milliseconds(), template.HTMLEscapeString(output)) +} + +func (s *Server) render(w http.ResponseWriter, name string, data any) { + t, ok := s.tmpls["templates/"+name] + if !ok { + http.Error(w, fmt.Sprintf("template %q not found", name), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := t.ExecuteTemplate(w, "layout", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/src/internal/ui/handlers_vault.go b/src/internal/ui/handlers_vault.go new file mode 100644 index 0000000..f19029a --- /dev/null +++ b/src/internal/ui/handlers_vault.go @@ -0,0 +1,13 @@ +// Copyright 2026 Jordan Sherer +// SPDX-License-Identifier: gpl + +package ui + +import "net/http" + +func (s *Server) handleVault(w http.ResponseWriter, r *http.Request) { + s.render(w, "vault.html", map[string]any{ + "Title": "Vault", + "Nav": "vault", + }) +} diff --git a/src/internal/ui/server.go b/src/internal/ui/server.go new file mode 100644 index 0000000..918c614 --- /dev/null +++ b/src/internal/ui/server.go @@ -0,0 +1,152 @@ +// Copyright 2026 Jordan Sherer +// SPDX-License-Identifier: gpl + +package ui + +import ( + "fmt" + "html/template" + "io/fs" + "net/http" + "os" + + "github.com/factorly-dev/factorly/internal/config" + "github.com/factorly-dev/factorly/internal/proxy" + "github.com/factorly-dev/factorly/internal/registry" + "github.com/factorly-dev/factorly/internal/templates" + "github.com/factorly-dev/factorly/internal/vault" +) + +// Server serves the Factorly web UI. +type Server struct { + cfg *config.Config + cfgPath string + toolsDir string + registry *registry.Registry + proxy *proxy.Proxy + vault vault.Backend + tmpls map[string]*template.Template + mux *http.ServeMux +} + +// Options configures the UI server. +type Options struct { + Config *config.Config + CfgPath string + ToolsDir string + Registry *registry.Registry + Proxy *proxy.Proxy + Vault vault.Backend +} + +// New creates a UI server. +func New(opts Options) (*Server, error) { + // Parse each page template individually with the shared layout. + // This avoids "content" redefinition conflicts between pages. + pages := []string{ + "templates/tools.html", + "templates/tool_edit.html", + "templates/templates.html", + "templates/template_detail.html", + "templates/vault.html", + } + tmpls := make(map[string]*template.Template, len(pages)) + for _, page := range pages { + t, err := template.New("").Funcs(templateFuncs()).ParseFS(templatesFS, + "templates/layout.html", + "templates/partials/*.html", + page, + ) + if err != nil { + return nil, fmt.Errorf("parsing template %s: %w", page, err) + } + tmpls[page] = t + } + + s := &Server{ + cfg: opts.Config, + cfgPath: opts.CfgPath, + toolsDir: opts.ToolsDir, + registry: opts.Registry, + proxy: opts.Proxy, + vault: opts.Vault, + tmpls: tmpls, + mux: http.NewServeMux(), + } + + s.routes() + return s, nil +} + +func (s *Server) routes() { + // Static assets (embedded under static/ subdir) + staticSub, _ := fs.Sub(staticFS, "static") + s.mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticSub))) + + // Tools + s.mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/tools", http.StatusFound) + }) + s.mux.HandleFunc("GET /tools", s.handleToolsList) + s.mux.HandleFunc("GET /tools/{name}", s.handleToolEdit) + s.mux.HandleFunc("POST /tools/{name}/try", s.handleToolTry) + + // Templates + s.mux.HandleFunc("GET /templates", s.handleTemplatesList) + s.mux.HandleFunc("GET /templates/{name}", s.handleTemplateDetail) + + // Vault (placeholder) + s.mux.HandleFunc("GET /vault", s.handleVault) +} + +// Start begins serving the UI. +func (s *Server) Start(addr string) error { + fmt.Fprintf(os.Stderr, "Factorly UI running at http://%s\n", addr) + return http.ListenAndServe(addr, s.mux) +} + +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "inc": func(i int) int { return i + 1 }, + "shadowSummary": func(sc *config.ShadowConfig) string { + if sc == nil { + return "none" + } + parts := []string{} + if len(sc.Deny) > 0 { + parts = append(parts, fmt.Sprintf("deny:%d", len(sc.Deny))) + } + confirmList, confirmAll := sc.ConfirmList() + if confirmAll { + parts = append(parts, "confirm:all") + } else if len(confirmList) > 0 { + parts = append(parts, fmt.Sprintf("confirm:%d", len(confirmList))) + } + if sc.RateLimit != "" { + parts = append(parts, sc.RateLimit) + } + if len(parts) == 0 { + return "none" + } + result := "" + for i, p := range parts { + if i > 0 { + result += ", " + } + result += p + } + return result + }, + "templateCategories": func() []string { + seen := map[string]bool{} + var cats []string + for _, t := range templates.All() { + if !seen[t.Category] { + seen[t.Category] = true + cats = append(cats, t.Category) + } + } + return cats + }, + } +} diff --git a/src/internal/ui/sse.go b/src/internal/ui/sse.go new file mode 100644 index 0000000..47f691d --- /dev/null +++ b/src/internal/ui/sse.go @@ -0,0 +1,41 @@ +// Copyright 2026 Jordan Sherer +// SPDX-License-Identifier: gpl + +package ui + +import ( + "fmt" + "net/http" + "strings" +) + +// SSEWriter writes Server-Sent Events to an HTTP response. +type SSEWriter struct { + w http.ResponseWriter + flusher http.Flusher +} + +// NewSSEWriter creates an SSE writer, setting appropriate headers. +func NewSSEWriter(w http.ResponseWriter) (*SSEWriter, error) { + flusher, ok := w.(http.Flusher) + if !ok { + return nil, fmt.Errorf("streaming not supported") + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + flusher.Flush() + + return &SSEWriter{w: w, flusher: flusher}, nil +} + +// Event sends a named event with data. +func (sw *SSEWriter) Event(event, data string) { + fmt.Fprintf(sw.w, "event: %s\n", event) + for _, line := range strings.Split(data, "\n") { + fmt.Fprintf(sw.w, "data: %s\n", line) + } + fmt.Fprint(sw.w, "\n") + sw.flusher.Flush() +} diff --git a/src/internal/ui/static/htmx.min.js b/src/internal/ui/static/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/src/internal/ui/static/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/src/internal/ui/static/sse.js b/src/internal/ui/static/sse.js new file mode 100644 index 0000000..9f5af5c --- /dev/null +++ b/src/internal/ui/static/sse.js @@ -0,0 +1,290 @@ +/* +Server Sent Events Extension +============================ +This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions. + +*/ + +(function() { + /** @type {import("../htmx").HtmxInternalApi} */ + var api + + htmx.defineExtension('sse', { + + /** + * Init saves the provided reference to the internal HTMX API. + * + * @param {import("../htmx").HtmxInternalApi} api + * @returns void + */ + init: function(apiRef) { + // store a reference to the internal API. + api = apiRef + + // set a function in the public API for creating new EventSource objects + if (htmx.createEventSource == undefined) { + htmx.createEventSource = createEventSource + } + }, + + getSelectors: function() { + return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]'] + }, + + /** + * onEvent handles all events passed to this extension. + * + * @param {string} name + * @param {Event} evt + * @returns void + */ + onEvent: function(name, evt) { + var parent = evt.target || evt.detail.elt + switch (name) { + case 'htmx:beforeCleanupElement': + var internalData = api.getInternalData(parent) + // Try to remove remove an EventSource when elements are removed + var source = internalData.sseEventSource + if (source) { + api.triggerEvent(parent, 'htmx:sseClose', { + source, + type: 'nodeReplaced', + }) + internalData.sseEventSource.close() + } + + return + + // Try to create EventSources when elements are processed + case 'htmx:afterProcessNode': + ensureEventSourceOnElement(parent) + } + } + }) + + /// //////////////////////////////////////////// + // HELPER FUNCTIONS + /// //////////////////////////////////////////// + + /** + * createEventSource is the default method for creating new EventSource objects. + * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed. + * + * @param {string} url + * @returns EventSource + */ + function createEventSource(url) { + return new EventSource(url, { withCredentials: true }) + } + + /** + * registerSSE looks for attributes that can contain sse events, right + * now hx-trigger and sse-swap and adds listeners based on these attributes too + * the closest event source + * + * @param {HTMLElement} elt + */ + function registerSSE(elt) { + // Add message handlers for every `sse-swap` attribute + if (api.getAttributeValue(elt, 'sse-swap')) { + // Find closest existing event source + var sourceElement = api.getClosestMatch(elt, hasEventSource) + if (sourceElement == null) { + // api.triggerErrorEvent(elt, "htmx:noSSESourceError") + return null // no eventsource in parentage, orphaned element + } + + // Set internalData and source + var internalData = api.getInternalData(sourceElement) + var source = internalData.sseEventSource + + var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap') + var sseEventNames = sseSwapAttr.split(',') + + for (var i = 0; i < sseEventNames.length; i++) { + const sseEventName = sseEventNames[i].trim() + const listener = function(event) { + // If the source is missing then close SSE + if (maybeCloseSSESource(sourceElement)) { + return + } + + // If the body no longer contains the element, remove the listener + if (!api.bodyContains(elt)) { + source.removeEventListener(sseEventName, listener) + return + } + + // swap the response into the DOM and trigger a notification + if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) { + return + } + swap(elt, event.data) + api.triggerEvent(elt, 'htmx:sseMessage', event) + } + + // Register the new listener + api.getInternalData(elt).sseEventListener = listener + source.addEventListener(sseEventName, listener) + } + } + + // Add message handlers for every `hx-trigger="sse:*"` attribute + if (api.getAttributeValue(elt, 'hx-trigger')) { + // Find closest existing event source + var sourceElement = api.getClosestMatch(elt, hasEventSource) + if (sourceElement == null) { + // api.triggerErrorEvent(elt, "htmx:noSSESourceError") + return null // no eventsource in parentage, orphaned element + } + + // Set internalData and source + var internalData = api.getInternalData(sourceElement) + var source = internalData.sseEventSource + + var triggerSpecs = api.getTriggerSpecs(elt) + triggerSpecs.forEach(function(ts) { + if (ts.trigger.slice(0, 4) !== 'sse:') { + return + } + + var listener = function (event) { + if (maybeCloseSSESource(sourceElement)) { + return + } + if (!api.bodyContains(elt)) { + source.removeEventListener(ts.trigger.slice(4), listener) + } + // Trigger events to be handled by the rest of htmx + htmx.trigger(elt, ts.trigger, event) + htmx.trigger(elt, 'htmx:sseMessage', event) + } + + // Register the new listener + api.getInternalData(elt).sseEventListener = listener + source.addEventListener(ts.trigger.slice(4), listener) + }) + } + } + + /** + * ensureEventSourceOnElement creates a new EventSource connection on the provided element. + * If a usable EventSource already exists, then it is returned. If not, then a new EventSource + * is created and stored in the element's internalData. + * @param {HTMLElement} elt + * @param {number} retryCount + * @returns {EventSource | null} + */ + function ensureEventSourceOnElement(elt, retryCount) { + if (elt == null) { + return null + } + + // handle extension source creation attribute + if (api.getAttributeValue(elt, 'sse-connect')) { + var sseURL = api.getAttributeValue(elt, 'sse-connect') + if (sseURL == null) { + return + } + + ensureEventSource(elt, sseURL, retryCount) + } + + registerSSE(elt) + } + + function ensureEventSource(elt, url, retryCount) { + var source = htmx.createEventSource(url) + + source.onerror = function(err) { + // Log an error event + api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source }) + + // If parent no longer exists in the document, then clean up this EventSource + if (maybeCloseSSESource(elt)) { + return + } + + // Otherwise, try to reconnect the EventSource + if (source.readyState === EventSource.CLOSED) { + retryCount = retryCount || 0 + retryCount = Math.max(Math.min(retryCount * 2, 128), 1) + var timeout = retryCount * 500 + window.setTimeout(function() { + ensureEventSourceOnElement(elt, retryCount) + }, timeout) + } + } + + source.onopen = function(evt) { + api.triggerEvent(elt, 'htmx:sseOpen', { source }) + + if (retryCount && retryCount > 0) { + const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]") + for (let i = 0; i < childrenToFix.length; i++) { + registerSSE(childrenToFix[i]) + } + // We want to increase the reconnection delay for consecutive failed attempts only + retryCount = 0 + } + } + + api.getInternalData(elt).sseEventSource = source + + + var closeAttribute = api.getAttributeValue(elt, "sse-close"); + if (closeAttribute) { + // close eventsource when this message is received + source.addEventListener(closeAttribute, function() { + api.triggerEvent(elt, 'htmx:sseClose', { + source, + type: 'message', + }) + source.close() + }); + } + } + + /** + * maybeCloseSSESource confirms that the parent element still exists. + * If not, then any associated SSE source is closed and the function returns true. + * + * @param {HTMLElement} elt + * @returns boolean + */ + function maybeCloseSSESource(elt) { + if (!api.bodyContains(elt)) { + var source = api.getInternalData(elt).sseEventSource + if (source != undefined) { + api.triggerEvent(elt, 'htmx:sseClose', { + source, + type: 'nodeMissing', + }) + source.close() + // source = null + return true + } + } + return false + } + + + /** + * @param {HTMLElement} elt + * @param {string} content + */ + function swap(elt, content) { + api.withExtensions(elt, function(extension) { + content = extension.transformResponse(content, null, elt) + }) + + var swapSpec = api.getSwapSpecification(elt) + var target = api.getTarget(elt) + api.swap(target, content, swapSpec) + } + + + function hasEventSource(node) { + return api.getInternalData(node).sseEventSource != null + } +})() diff --git a/src/internal/ui/static/style.css b/src/internal/ui/static/style.css new file mode 100644 index 0000000..39f19a8 --- /dev/null +++ b/src/internal/ui/static/style.css @@ -0,0 +1,2 @@ +/* Factorly UI — minimal custom styles beyond Tailwind */ +.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } diff --git a/src/internal/ui/templates/layout.html b/src/internal/ui/templates/layout.html new file mode 100644 index 0000000..d8cfdf2 --- /dev/null +++ b/src/internal/ui/templates/layout.html @@ -0,0 +1,27 @@ +{{define "layout"}} + + + + + Factorly{{if .Title}} — {{.Title}}{{end}} + + + + + + + +
+ {{template "content" .}} +
+ +{{end}} diff --git a/src/internal/ui/templates/partials/empty.html b/src/internal/ui/templates/partials/empty.html new file mode 100644 index 0000000..f96d305 --- /dev/null +++ b/src/internal/ui/templates/partials/empty.html @@ -0,0 +1 @@ +{{define "empty"}}{{end}} diff --git a/src/internal/ui/templates/template_detail.html b/src/internal/ui/templates/template_detail.html new file mode 100644 index 0000000..bbb1c29 --- /dev/null +++ b/src/internal/ui/templates/template_detail.html @@ -0,0 +1,43 @@ +{{define "content"}} + + +
+

{{.Template.DisplayName}}

+ {{.Template.Category}} +
+

{{.Template.Description}}

+ +
+ +
+

Tools included

+
+ {{range .ToolNames}} +
{{.}}
+ {{end}} +
+
+ + +
+

Authentication

+
+ {{.Template.AuthType}} + {{if .Template.VaultKey}} + vault key: {{.Template.VaultKey}} + {{end}} +
+ {{if .Template.AuthGuide}} +
{{.Template.AuthGuide}}
+ {{end}} +
+
+ + +
+

YAML

+
{{.YAML}}
+
+{{end}} diff --git a/src/internal/ui/templates/templates.html b/src/internal/ui/templates/templates.html new file mode 100644 index 0000000..c4b92f2 --- /dev/null +++ b/src/internal/ui/templates/templates.html @@ -0,0 +1,17 @@ +{{define "content"}} +

Templates

+

Pre-built tool configurations. Pick one to add to your project.

+ + +{{end}} diff --git a/src/internal/ui/templates/tool_edit.html b/src/internal/ui/templates/tool_edit.html new file mode 100644 index 0000000..e7fb93a --- /dev/null +++ b/src/internal/ui/templates/tool_edit.html @@ -0,0 +1,99 @@ +{{define "content"}} + + +
+

{{.Tool.Name}}

+ {{.Tool.Type}} +
+ +{{if .Tool.Description}} +

{{.Tool.Description}}

+{{end}} + +
+ +
+

Configuration

+ + {{if eq .Tool.Type "cli"}} +
+
Command
{{.Tool.Command}}
+ {{if .Tool.Args}}
Args
{{range .Tool.Args}}{{.}} {{end}}
{{end}} + {{if .Tool.Stdin}}
Stdin
{{.Tool.Stdin}}
{{end}} +
+ {{else if eq .Tool.Type "rest"}} +
+
Method
{{.Tool.Method}}
+
Base URL
{{.Tool.BaseURL}}
+
Path
{{.Tool.Path}}
+ {{if .Tool.Body}}
Body
{{.Tool.Body}}
{{end}} +
+ {{else if eq .Tool.Type "mcp"}} +
+ {{if .Tool.Command}}
Command
{{.Tool.Command}}
{{end}} + {{if .Tool.URL}}
URL
{{.Tool.URL}}
{{end}} +
+ {{else if eq .Tool.Type "workflow"}} +
+

Steps

+ {{range $i, $step := .Tool.Steps}} +
+ {{inc $i}} + {{$step.Tool}} + {{if $step.Store}}→ {{$step.Store}}{{end}} +
+ {{end}} +
+ {{end}} + + {{if .Tool.Shadow}} +
+

Oversight

+ {{shadowSummary .Tool.Shadow}} +
+ {{end}} +
+ + +
+

Try it

+ +
+ + {{if .Params}} +
+ {{range .Params}} +
+ + {{if .Description}} +

{{.Description}}

+ {{end}} + +
+ {{end}} +
+ {{else}} +

No parameters required.

+ {{end}} + + + running... +
+ +
+
+
+{{end}} diff --git a/src/internal/ui/templates/tools.html b/src/internal/ui/templates/tools.html new file mode 100644 index 0000000..eae7e84 --- /dev/null +++ b/src/internal/ui/templates/tools.html @@ -0,0 +1,33 @@ +{{define "content"}} +
+

Tools

+ {{len .Tools}} configured +
+ +{{if not .Tools}} +
+ No tools configured. Browse templates to get started. +
+{{else}} + +{{end}} +{{end}} diff --git a/src/internal/ui/templates/vault.html b/src/internal/ui/templates/vault.html new file mode 100644 index 0000000..ff9f016 --- /dev/null +++ b/src/internal/ui/templates/vault.html @@ -0,0 +1,4 @@ +{{define "content"}} +

Vault

+

Coming soon.

+{{end}} From 54d4523f20b3e22f2a16388adaa2957683be1ed5 Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Sun, 3 May 2026 00:32:27 -0400 Subject: [PATCH 02/65] =?UTF-8?q?Make=20factorly=20ui=20functional=20?= =?UTF-8?q?=E2=80=94=20tool=20CRUD,=20vault,=20workflows,=20template=20ins?= =?UTF-8?q?tall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial UI commit was scaffolding only — forms didn't submit, vault wasn't wired, dynamic fields 404'd. This makes everything actually work. Critical fixes: - Wire vault to UI server: call getCachedVault() after bootstrapProviders() (cached singleton, won't re-prompt) and pass to ui.Options.Vault. Tools with vault refs now execute correctly from "Try it" panel. - Add /tools/_form handler returning type-specific HTML partials (CLI: command+args+stdin, REST: method+url+path, Workflow: info) swapped via htmx on type dropdown change in new tool form Tool CRUD (handlers_tools.go + yamlio.go): - handleToolNew: renders new tool form - handleToolCreate: POST /tools/_new, builds ToolConfig from form, writes YAML via SaveTool (prefers tools_dir/, falls back to main config), redirects to editor - handleToolSave: POST /tools/{name}, updates all type-specific fields (CLI: command/args/stdin, REST: method/url/path/body, MCP: command/args), writes to YAML - handleToolDelete: DELETE /tools/{name}, removes from disk and in-memory config, returns HX-Redirect header - handleToolFormPartial: GET /tools/_form?type=X, returns HTML partial for the selected tool type - splitArgs: space-separated arg parser respecting quotes - yamlio.go: SaveTool/DeleteTool with tools_dir vs main config strategies, generic map-based YAML preservation Tool edit form (tool_edit.html): - Full editable form with type-specific fields always visible (removed {{if .Tool.Stdin}} / {{if .Tool.Body}} conditionals that hid empty fields) - Delete button with hx-confirm dialog - Save button persists to YAML - joinArgs template function for rendering args back to string - "New Tool" button added to tools list page Vault management (handlers_vault.go + vault.html): - handleVault: lists keys (never values), shows "not available" if no vault backend - handleVaultSet: POST /vault with key+value, returns updated key list as htmx partial - handleVaultDelete: DELETE /vault/{key} with confirm, returns updated list - renderVaultKeys: error handling (shows error message instead of silently rendering empty list) - Password inputs for secret values, ••••••• placeholders Workflow builder (handlers_workflows.go + workflow_edit.html): - handleWorkflowEdit: shows step list with tool dropdowns populated from non-workflow tools, store fields, run panel - handleWorkflowSave: POST rebuilds steps from form indices, saves to YAML - handleWorkflowRun: executes through proxy, parses JSON output, renders structured result with per-step status icons (✓/✗/—), duration, and error details - JavaScript addStep/removeStep: clone step rows from dropdown options, auto-renumber indices on remove - Workflow tools redirect from /tools/{name} to /workflows/{name} Template install (handlers_templates.go + template_detail.html): - handleTemplateInstall: POST /templates/{name}/install, gets full YAML via FilterYAML, writes to tools_dir/ or merges into main config, redirects to /tools - "Install Template" button on detail page Route registration (server.go): - GET /tools/_form (type partials) - GET /tools/new, POST /tools/_new (create) - POST /tools/{name} (save), DELETE /tools/{name} (delete) - POST /templates/{name}/install - GET /workflows/{name}, POST /workflows/{name}/save, POST /workflows/{name}/run - POST /vault, DELETE /vault/{key} --- src/cmd/factorly/ui_cmd.go | 6 + src/internal/ui/handlers_templates.go | 66 ++++++ src/internal/ui/handlers_tools.go | 200 ++++++++++++++++++ src/internal/ui/handlers_vault.go | 95 ++++++++- src/internal/ui/handlers_workflows.go | 192 +++++++++++++++++ src/internal/ui/server.go | 29 ++- .../ui/templates/template_detail.html | 11 + src/internal/ui/templates/tool_edit.html | 127 +++++++---- src/internal/ui/templates/tool_new.html | 62 ++++++ src/internal/ui/templates/tools.html | 5 +- src/internal/ui/templates/vault.html | 52 ++++- src/internal/ui/templates/workflow_edit.html | 124 +++++++++++ src/internal/ui/yamlio.go | 125 +++++++++++ 13 files changed, 1046 insertions(+), 48 deletions(-) create mode 100644 src/internal/ui/handlers_workflows.go create mode 100644 src/internal/ui/templates/tool_new.html create mode 100644 src/internal/ui/templates/workflow_edit.html create mode 100644 src/internal/ui/yamlio.go diff --git a/src/cmd/factorly/ui_cmd.go b/src/cmd/factorly/ui_cmd.go index 17e9f75..ed5df76 100644 --- a/src/cmd/factorly/ui_cmd.go +++ b/src/cmd/factorly/ui_cmd.go @@ -9,6 +9,7 @@ import ( "runtime" "github.com/factorly-dev/factorly/internal/ui" + "github.com/factorly-dev/factorly/internal/vault" "github.com/spf13/cobra" ) @@ -37,6 +38,10 @@ func runUI(cmd *cobra.Command, args []string) error { } defer p.Teardown() + // Get vault backend (cached singleton from bootstrapProviders, won't re-prompt) + var vaultBackend vault.Backend + vaultBackend, _ = getCachedVault() + addr := fmt.Sprintf("localhost:%d", uiPort) srv, err := ui.New(ui.Options{ @@ -45,6 +50,7 @@ func runUI(cmd *cobra.Command, args []string) error { ToolsDir: cfg.ToolsDir, Registry: reg, Proxy: p, + Vault: vaultBackend, }) if err != nil { return err diff --git a/src/internal/ui/handlers_templates.go b/src/internal/ui/handlers_templates.go index eaf85ef..229a713 100644 --- a/src/internal/ui/handlers_templates.go +++ b/src/internal/ui/handlers_templates.go @@ -4,9 +4,13 @@ package ui import ( + "fmt" "net/http" + "os" + "path/filepath" "github.com/factorly-dev/factorly/internal/templates" + "gopkg.in/yaml.v3" ) func (s *Server) handleTemplatesList(w http.ResponseWriter, r *http.Request) { @@ -33,3 +37,65 @@ func (s *Server) handleTemplateDetail(w http.ResponseWriter, r *http.Request) { "YAML": tmpl.YAML, }) } + +func (s *Server) handleTemplateInstall(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + tmpl := templates.Get(name) + if tmpl == nil { + http.NotFound(w, r) + return + } + + // Get the full YAML for all tools in the template + toolYAML := tmpl.FilterYAML(tmpl.ToolNames()) + + // Write to tools_dir if configured, otherwise to main config + if s.toolsDir != "" { + if err := os.MkdirAll(s.toolsDir, 0o755); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + outPath := filepath.Join(s.toolsDir, name+".yaml") + if err := os.WriteFile(outPath, []byte(toolYAML), 0o644); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } else if s.cfgPath != "" { + // Merge into main config + var newTools map[string]any + if err := yaml.Unmarshal([]byte(toolYAML), &newTools); err != nil { + http.Error(w, fmt.Sprintf("parsing template YAML: %v", err), http.StatusInternalServerError) + return + } + + data, err := os.ReadFile(s.cfgPath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + var raw map[string]any + if err := yaml.Unmarshal(data, &raw); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + tools, ok := raw["tools"].(map[string]any) + if !ok { + tools = make(map[string]any) + raw["tools"] = tools + } + for k, v := range newTools { + tools[k] = v + } + out, err := yaml.Marshal(raw) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := os.WriteFile(s.cfgPath, out, 0o644); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + http.Redirect(w, r, "/tools", http.StatusFound) +} diff --git a/src/internal/ui/handlers_tools.go b/src/internal/ui/handlers_tools.go index 2d47463..b46c45b 100644 --- a/src/internal/ui/handlers_tools.go +++ b/src/internal/ui/handlers_tools.go @@ -48,6 +48,12 @@ func (s *Server) handleToolEdit(w http.ResponseWriter, r *http.Request) { return } + // Redirect workflows to their dedicated editor + if tc.Type == "workflow" { + http.Redirect(w, r, "/workflows/"+name, http.StatusFound) + return + } + // Combine tool config name with struct for template type toolView struct { Name string @@ -119,6 +125,200 @@ func (s *Server) handleToolTry(w http.ResponseWriter, r *http.Request) { `, statusColor, statusColor, statusColor, status, duration.Milliseconds(), template.HTMLEscapeString(output)) } +func (s *Server) handleToolFormPartial(w http.ResponseWriter, r *http.Request) { + toolType := r.URL.Query().Get("type") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + switch toolType { + case "rest": + fmt.Fprint(w, `
+
+ + +
+
+ + +
+
+ + +
+
`) + case "workflow": + fmt.Fprint(w, `
+

Workflow steps can be configured after creation.

+
`) + default: // cli + fmt.Fprint(w, `
+
+ + +
+
+ + +

Space-separated. Use {{param}} for placeholders.

+
+
`) + } +} + +func (s *Server) handleToolNew(w http.ResponseWriter, r *http.Request) { + s.render(w, "tool_new.html", map[string]any{ + "Title": "New Tool", + "Nav": "tools", + }) +} + +func (s *Server) handleToolCreate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + name := r.FormValue("name") + if name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + + tc := config.ToolConfig{ + Type: r.FormValue("type"), + Description: r.FormValue("description"), + } + + switch tc.Type { + case "cli": + tc.Command = r.FormValue("command") + if args := r.FormValue("args"); args != "" { + tc.Args = splitArgs(args) + } + case "rest": + tc.BaseURL = r.FormValue("base_url") + tc.Method = r.FormValue("method") + tc.Path = r.FormValue("path") + } + + if err := SaveTool(s.cfgPath, s.toolsDir, name, tc); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Reload config + s.cfg.Tools[name] = tc + + http.Redirect(w, r, "/tools/"+name, http.StatusFound) +} + +func (s *Server) handleToolSave(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + tc, ok := s.cfg.Tools[name] + if !ok { + http.NotFound(w, r) + return + } + + tc.Description = r.FormValue("description") + switch tc.Type { + case "cli": + tc.Command = r.FormValue("command") + if args := r.FormValue("args"); args != "" { + tc.Args = splitArgs(args) + } else { + tc.Args = nil + } + if stdin := r.FormValue("stdin"); stdin != "" { + tc.Stdin = stdin + } + case "rest": + tc.BaseURL = r.FormValue("base_url") + tc.Method = r.FormValue("method") + tc.Path = r.FormValue("path") + if body := r.FormValue("body"); body != "" { + tc.Body = body + } + case "mcp": + tc.Command = r.FormValue("command") + if args := r.FormValue("args"); args != "" { + tc.Args = splitArgs(args) + } else { + tc.Args = nil + } + } + + if err := SaveTool(s.cfgPath, s.toolsDir, name, tc); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.cfg.Tools[name] = tc + http.Redirect(w, r, "/tools/"+name, http.StatusFound) +} + +func (s *Server) handleToolDelete(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + + if err := DeleteTool(s.cfgPath, s.toolsDir, name); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + delete(s.cfg.Tools, name) + + // Return empty response for htmx (redirects via HX-Redirect header) + w.Header().Set("HX-Redirect", "/tools") + w.WriteHeader(http.StatusOK) +} + +// splitArgs splits a space-separated string respecting quoted segments. +func splitArgs(s string) []string { + var args []string + var current []byte + inQuote := false + quoteChar := byte(0) + + for i := 0; i < len(s); i++ { + c := s[i] + if inQuote { + if c == quoteChar { + inQuote = false + } else { + current = append(current, c) + } + } else if c == '"' || c == '\'' { + inQuote = true + quoteChar = c + } else if c == ' ' { + if len(current) > 0 { + args = append(args, string(current)) + current = current[:0] + } + } else { + current = append(current, c) + } + } + if len(current) > 0 { + args = append(args, string(current)) + } + return args +} + func (s *Server) render(w http.ResponseWriter, name string, data any) { t, ok := s.tmpls["templates/"+name] if !ok { diff --git a/src/internal/ui/handlers_vault.go b/src/internal/ui/handlers_vault.go index f19029a..3fcc366 100644 --- a/src/internal/ui/handlers_vault.go +++ b/src/internal/ui/handlers_vault.go @@ -3,11 +3,100 @@ package ui -import "net/http" +import ( + "fmt" + "net/http" +) func (s *Server) handleVault(w http.ResponseWriter, r *http.Request) { + var keys []string + available := s.vault != nil + + if available { + var err error + keys, err = s.vault.List() + if err != nil { + available = false + } + } + s.render(w, "vault.html", map[string]any{ - "Title": "Vault", - "Nav": "vault", + "Title": "Vault", + "Nav": "vault", + "Available": available, + "Keys": keys, }) } + +func (s *Server) handleVaultSet(w http.ResponseWriter, r *http.Request) { + if s.vault == nil { + http.Error(w, "vault not available", http.StatusServiceUnavailable) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + key := r.FormValue("key") + value := r.FormValue("value") + if key == "" || value == "" { + http.Error(w, "key and value required", http.StatusBadRequest) + return + } + + if err := s.vault.Set(key, value); err != nil { + http.Error(w, fmt.Sprintf("vault set: %v", err), http.StatusInternalServerError) + return + } + + // Return updated key list as HTML partial for htmx swap + s.renderVaultKeys(w) +} + +func (s *Server) handleVaultDelete(w http.ResponseWriter, r *http.Request) { + if s.vault == nil { + http.Error(w, "vault not available", http.StatusServiceUnavailable) + return + } + + key := r.PathValue("key") + if err := s.vault.Delete(key); err != nil { + http.Error(w, fmt.Sprintf("vault delete: %v", err), http.StatusInternalServerError) + return + } + + // Return updated key list + s.renderVaultKeys(w) +} + +func (s *Server) renderVaultKeys(w http.ResponseWriter) { + keys, err := s.vault.List() + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err != nil { + fmt.Fprintf(w, `
Error listing keys: %s
`, err.Error()) + return + } + if len(keys) == 0 { + fmt.Fprint(w, `
No secrets stored.
`) + return + } + + fmt.Fprint(w, `
`) + for _, key := range keys { + fmt.Fprintf(w, `
+ %s +
+ •••••••• + +
+
`, key, key, key) + } + fmt.Fprint(w, `
`) +} diff --git a/src/internal/ui/handlers_workflows.go b/src/internal/ui/handlers_workflows.go new file mode 100644 index 0000000..a5b010d --- /dev/null +++ b/src/internal/ui/handlers_workflows.go @@ -0,0 +1,192 @@ +// Copyright 2026 Jordan Sherer +// SPDX-License-Identifier: gpl + +package ui + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "net/http" + "sort" + "strconv" + "time" + + "github.com/factorly-dev/factorly/internal/config" +) + +func (s *Server) handleWorkflowEdit(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + tc, ok := s.cfg.Tools[name] + if !ok || tc.Type != "workflow" { + http.NotFound(w, r) + return + } + + // List all non-workflow tools for step dropdown + var available []string + for n, t := range s.cfg.Tools { + if t.Type != "workflow" { + available = append(available, n) + } + } + sort.Strings(available) + + s.render(w, "workflow_edit.html", map[string]any{ + "Title": name, + "Nav": "tools", + "Name": name, + "Description": tc.Description, + "Steps": tc.Steps, + "Params": tc.Parameters, + "AvailableTools": available, + }) +} + +func (s *Server) handleWorkflowSave(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + tc, ok := s.cfg.Tools[name] + if !ok { + http.NotFound(w, r) + return + } + + // Rebuild steps from form + var steps []config.StepConfig + for i := 0; ; i++ { + tool := r.FormValue(fmt.Sprintf("step_tool_%d", i)) + if tool == "" { + break + } + store := r.FormValue(fmt.Sprintf("step_store_%d", i)) + steps = append(steps, config.StepConfig{ + Tool: tool, + Store: store, + }) + } + + tc.Steps = steps + if err := SaveTool(s.cfgPath, s.toolsDir, name, tc); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + s.cfg.Tools[name] = tc + + http.Redirect(w, r, "/workflows/"+name, http.StatusFound) +} + +func (s *Server) handleWorkflowRun(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + params := make(map[string]string) + for key, vals := range r.Form { + if len(key) > 6 && key[:6] == "param_" { + params[key[6:]] = vals[0] + } + } + + start := time.Now() + ctx := context.Background() + result, execErr := s.proxy.ExecuteWithContext(ctx, name, params, "ui") + duration := time.Since(start) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + if execErr != nil { + fmt.Fprintf(w, `
+
+ Failed + %dms +
+
%s
+
`, duration.Milliseconds(), template.HTMLEscapeString(execErr.Error())) + return + } + + // Parse workflow JSON output for nice rendering + output := "" + if result != nil { + output = result.Output + } + + var wfResult struct { + Status string `json:"status"` + Steps []struct { + Tool string `json:"tool"` + Status string `json:"status"` + DurationMs int `json:"duration_ms"` + Error string `json:"error,omitempty"` + } `json:"steps"` + Result string `json:"result"` + Error string `json:"error,omitempty"` + } + + if err := json.Unmarshal([]byte(output), &wfResult); err != nil { + // Couldn't parse as workflow JSON — show raw + fmt.Fprintf(w, `
%s
`, + template.HTMLEscapeString(output)) + return + } + + // Render structured workflow result + statusColor := "green" + if wfResult.Status == "failed" { + statusColor = "red" + } + + fmt.Fprintf(w, `
`, statusColor, statusColor) + fmt.Fprintf(w, `
+ %s + %dms +
`, statusColor, wfResult.Status, duration.Milliseconds()) + + // Step list + fmt.Fprint(w, `
`) + for _, step := range wfResult.Steps { + icon := "✓" + color := "green" + if step.Status == "failed" { + icon = "✗" + color = "red" + } else if step.Status == "skipped" { + icon = "—" + color = "gray" + } + dur := "" + if step.DurationMs > 0 { + dur = strconv.Itoa(step.DurationMs) + "ms" + } + fmt.Fprintf(w, `
+ %s + %s + %s + %s +
`, color, icon, step.Tool, step.Status, dur) + if step.Error != "" { + fmt.Fprintf(w, `
%s
`, template.HTMLEscapeString(step.Error)) + } + } + fmt.Fprint(w, `
`) + + // Result + if wfResult.Result != "" { + fmt.Fprintf(w, `
%s
`, + statusColor, template.HTMLEscapeString(wfResult.Result)) + } + if wfResult.Error != "" { + fmt.Fprintf(w, `
%s
`, + template.HTMLEscapeString(wfResult.Error)) + } + + fmt.Fprint(w, `
`) +} diff --git a/src/internal/ui/server.go b/src/internal/ui/server.go index 918c614..10fa4d6 100644 --- a/src/internal/ui/server.go +++ b/src/internal/ui/server.go @@ -9,6 +9,7 @@ import ( "io/fs" "net/http" "os" + "strings" "github.com/factorly-dev/factorly/internal/config" "github.com/factorly-dev/factorly/internal/proxy" @@ -46,8 +47,10 @@ func New(opts Options) (*Server, error) { pages := []string{ "templates/tools.html", "templates/tool_edit.html", + "templates/tool_new.html", "templates/templates.html", "templates/template_detail.html", + "templates/workflow_edit.html", "templates/vault.html", } tmpls := make(map[string]*template.Template, len(pages)) @@ -88,15 +91,28 @@ func (s *Server) routes() { http.Redirect(w, r, "/tools", http.StatusFound) }) s.mux.HandleFunc("GET /tools", s.handleToolsList) + s.mux.HandleFunc("GET /tools/new", s.handleToolNew) + s.mux.HandleFunc("GET /tools/_form", s.handleToolFormPartial) s.mux.HandleFunc("GET /tools/{name}", s.handleToolEdit) + s.mux.HandleFunc("POST /tools/_new", s.handleToolCreate) + s.mux.HandleFunc("POST /tools/{name}", s.handleToolSave) s.mux.HandleFunc("POST /tools/{name}/try", s.handleToolTry) + s.mux.HandleFunc("DELETE /tools/{name}", s.handleToolDelete) // Templates s.mux.HandleFunc("GET /templates", s.handleTemplatesList) s.mux.HandleFunc("GET /templates/{name}", s.handleTemplateDetail) + s.mux.HandleFunc("POST /templates/{name}/install", s.handleTemplateInstall) - // Vault (placeholder) + // Workflows + s.mux.HandleFunc("GET /workflows/{name}", s.handleWorkflowEdit) + s.mux.HandleFunc("POST /workflows/{name}/save", s.handleWorkflowSave) + s.mux.HandleFunc("POST /workflows/{name}/run", s.handleWorkflowRun) + + // Vault s.mux.HandleFunc("GET /vault", s.handleVault) + s.mux.HandleFunc("POST /vault", s.handleVaultSet) + s.mux.HandleFunc("DELETE /vault/{key}", s.handleVaultDelete) } // Start begins serving the UI. @@ -108,6 +124,17 @@ func (s *Server) Start(addr string) error { func templateFuncs() template.FuncMap { return template.FuncMap{ "inc": func(i int) int { return i + 1 }, + "joinArgs": func(args []string) string { + var parts []string + for _, a := range args { + if strings.Contains(a, " ") { + parts = append(parts, `"`+a+`"`) + } else { + parts = append(parts, a) + } + } + return strings.Join(parts, " ") + }, "shadowSummary": func(sc *config.ShadowConfig) string { if sc == nil { return "none" diff --git a/src/internal/ui/templates/template_detail.html b/src/internal/ui/templates/template_detail.html index bbb1c29..fca6b1a 100644 --- a/src/internal/ui/templates/template_detail.html +++ b/src/internal/ui/templates/template_detail.html @@ -35,6 +35,17 @@

Authentication

+ +
+
+ + Adds tools to your project config +
+
+

YAML

diff --git a/src/internal/ui/templates/tool_edit.html b/src/internal/ui/templates/tool_edit.html index e7fb93a..4ad949e 100644 --- a/src/internal/ui/templates/tool_edit.html +++ b/src/internal/ui/templates/tool_edit.html @@ -3,57 +3,100 @@ ← Back to tools
-
-

{{.Tool.Name}}

- {{.Tool.Type}} +
+
+

{{.Tool.Name}}

+ {{.Tool.Type}} +
+
-{{if .Tool.Description}} -

{{.Tool.Description}}

-{{end}} -
- +

Configuration

- {{if eq .Tool.Type "cli"}} -
-
Command
{{.Tool.Command}}
- {{if .Tool.Args}}
Args
{{range .Tool.Args}}{{.}} {{end}}
{{end}} - {{if .Tool.Stdin}}
Stdin
{{.Tool.Stdin}}
{{end}} -
- {{else if eq .Tool.Type "rest"}} -
-
Method
{{.Tool.Method}}
-
Base URL
{{.Tool.BaseURL}}
-
Path
{{.Tool.Path}}
- {{if .Tool.Body}}
Body
{{.Tool.Body}}
{{end}} -
- {{else if eq .Tool.Type "mcp"}} -
- {{if .Tool.Command}}
Command
{{.Tool.Command}}
{{end}} - {{if .Tool.URL}}
URL
{{.Tool.URL}}
{{end}} -
- {{else if eq .Tool.Type "workflow"}} -
-

Steps

- {{range $i, $step := .Tool.Steps}} -
- {{inc $i}} - {{$step.Tool}} - {{if $step.Store}}→ {{$step.Store}}{{end}} +
+
+ + +
+ + {{if eq .Tool.Type "cli"}} +
+ + +
+
+ + +

Space-separated. Quote args with spaces.

+
+
+ + +
+ + {{else if eq .Tool.Type "rest"}} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + {{else if eq .Tool.Type "mcp"}} +
+ + +
+
+ + +
+ {{end}} + + {{if .Tool.Shadow}} +
+

Oversight

+ {{shadowSummary .Tool.Shadow}}
{{end}} -
- {{end}} - {{if .Tool.Shadow}} -
-

Oversight

- {{shadowSummary .Tool.Shadow}} -
- {{end}} +
+ +
+
diff --git a/src/internal/ui/templates/tool_new.html b/src/internal/ui/templates/tool_new.html new file mode 100644 index 0000000..cec7172 --- /dev/null +++ b/src/internal/ui/templates/tool_new.html @@ -0,0 +1,62 @@ +{{define "content"}} + + +

New Tool

+ +
+
+
+ + +

Dot-separated name (e.g., github.list_repos, slack.post_message)

+
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ + +
+
+ + +

Space-separated. Use {{"{{param}}"}} for placeholders.

+
+
+
+ +
+ +
+
+
+{{end}} diff --git a/src/internal/ui/templates/tools.html b/src/internal/ui/templates/tools.html index eae7e84..27c7d64 100644 --- a/src/internal/ui/templates/tools.html +++ b/src/internal/ui/templates/tools.html @@ -1,7 +1,10 @@ {{define "content"}}

Tools

- {{len .Tools}} configured +
+ {{len .Tools}} configured + + New Tool +
{{if not .Tools}} diff --git a/src/internal/ui/templates/vault.html b/src/internal/ui/templates/vault.html index ff9f016..2393d18 100644 --- a/src/internal/ui/templates/vault.html +++ b/src/internal/ui/templates/vault.html @@ -1,4 +1,54 @@ {{define "content"}}

Vault

-

Coming soon.

+ +{{if not .Available}} +
+ Vault not available. Run factorly vault set KEY value from the CLI to initialize. +
+{{else}} + + +
+

Add Secret

+
+ + + +
+
+ + +
+
+

Stored Keys

+
+
+ {{if not .Keys}} +
No secrets stored.
+ {{else}} +
+ {{range .Keys}} +
+ {{.}} +
+ •••••••• + +
+
+ {{end}} +
+ {{end}} +
+
+ +{{end}} {{end}} diff --git a/src/internal/ui/templates/workflow_edit.html b/src/internal/ui/templates/workflow_edit.html new file mode 100644 index 0000000..4b3d86b --- /dev/null +++ b/src/internal/ui/templates/workflow_edit.html @@ -0,0 +1,124 @@ +{{define "content"}} + + +
+
+

{{.Name}}

+ workflow +
+ +
+ +{{if .Description}} +

{{.Description}}

+{{end}} + +
+ +
+
+

Steps

+ +
+ +
+
+ {{range $i, $step := .Steps}} +
+ {{inc $i}} + + + +
+ {{end}} +
+ + +
+
+ + +
+

Run

+ +
+ + {{if .Params}} +
+ {{range .Params}} +
+ + +
+ {{end}} +
+ {{else}} +

No parameters required.

+ {{end}} + + + running... +
+ +
+
+
+ + +{{end}} diff --git a/src/internal/ui/yamlio.go b/src/internal/ui/yamlio.go new file mode 100644 index 0000000..ba8df4a --- /dev/null +++ b/src/internal/ui/yamlio.go @@ -0,0 +1,125 @@ +// Copyright 2026 Jordan Sherer +// SPDX-License-Identifier: gpl + +package ui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/factorly-dev/factorly/internal/config" + "gopkg.in/yaml.v3" +) + +// SaveTool writes a tool config to disk. If toolsDir is set, writes as +// individual file. Otherwise appends/updates in the main config file. +func SaveTool(cfgPath, toolsDir, name string, tc config.ToolConfig) error { + if toolsDir != "" { + return saveToolToDir(toolsDir, name, tc) + } + return saveToolToConfig(cfgPath, name, tc) +} + +// DeleteTool removes a tool from disk. +func DeleteTool(cfgPath, toolsDir, name string) error { + if toolsDir != "" { + return deleteToolFromDir(toolsDir, name) + } + return deleteToolFromConfig(cfgPath, name) +} + +func saveToolToDir(toolsDir, name string, tc config.ToolConfig) error { + if err := os.MkdirAll(toolsDir, 0o755); err != nil { + return err + } + filename := toolFilename(name) + path := filepath.Join(toolsDir, filename) + + data := map[string]config.ToolConfig{name: tc} + out, err := yaml.Marshal(data) + if err != nil { + return fmt.Errorf("marshaling tool: %w", err) + } + return os.WriteFile(path, out, 0o644) +} + +func deleteToolFromDir(toolsDir, name string) error { + filename := toolFilename(name) + path := filepath.Join(toolsDir, filename) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func saveToolToConfig(cfgPath, name string, tc config.ToolConfig) error { + // Read existing config + data, err := os.ReadFile(cfgPath) + if err != nil { + return err + } + + // Parse into a generic map to preserve structure + var raw map[string]any + if err := yaml.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("parsing config: %w", err) + } + + // Ensure tools map exists + tools, ok := raw["tools"].(map[string]any) + if !ok { + tools = make(map[string]any) + raw["tools"] = tools + } + + // Marshal the tool config to generic form and insert + toolBytes, err := yaml.Marshal(tc) + if err != nil { + return err + } + var toolMap any + if err := yaml.Unmarshal(toolBytes, &toolMap); err != nil { + return err + } + tools[name] = toolMap + + // Write back + out, err := yaml.Marshal(raw) + if err != nil { + return err + } + return os.WriteFile(cfgPath, out, 0o644) +} + +func deleteToolFromConfig(cfgPath, name string) error { + data, err := os.ReadFile(cfgPath) + if err != nil { + return err + } + + var raw map[string]any + if err := yaml.Unmarshal(data, &raw); err != nil { + return err + } + + tools, ok := raw["tools"].(map[string]any) + if !ok { + return nil + } + delete(tools, name) + + out, err := yaml.Marshal(raw) + if err != nil { + return err + } + return os.WriteFile(cfgPath, out, 0o644) +} + +func toolFilename(name string) string { + // Convert tool name to filename: github.list_repos → github.list_repos.yaml + // Replace path separators for safety + safe := strings.ReplaceAll(name, "/", "_") + return safe + ".yaml" +} From 1e27a0a8d368babb9dac84f06fe7199839a30235 Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Mon, 4 May 2026 11:21:31 -0400 Subject: [PATCH 03/65] Redesign UI with Postman-style response panel and persistent sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the UI feel like Postman: formatted responses, syntax highlighting, persistent tool sidebar, and a clear Send → Result feedback loop. Response panel (the "Send" moment): - Dark terminal-style response area (bg-gray-900) matching Postman's aesthetic for output display - Status bar with colored icon (✓ green, ✗ red), tool name, and duration in milliseconds - JSON auto-detection: pretty-prints compact JSON with indentation, then syntax-highlights (keys=indigo, strings=green, numbers=amber, booleans=amber, brackets=gray, null=gray) - Response/Raw tabs — toggle between highlighted formatted view and plain unformatted text via inline script - Blocked responses: red status bar + error on dark background - "▶ Send" button (renamed from "Run") with animated pulse indicator during execution - formatJSONHTML: custom syntax highlighter that walks the JSON string and wraps tokens in colored spans (no external library) Persistent sidebar: - 240px left sidebar with all tools listed, visible on all tools pages - Type-colored dots: gray=CLI, blue=REST, purple=MCP, green=workflow - Active tool highlighted with indigo right border + light background - Filter/search input at top (instant client-side filtering via JS) - "+ New Tool" button in sidebar footer - Sidebar only renders on Nav="tools" pages (templates/vault get full width) - getSidebarTools() auto-injected via render() when Nav is "tools" - ActiveTool passed from handleToolEdit for highlight state Layout restructure: - Body switched to flexbox column (nav + content area) - Content area is flex row (sidebar + main), both overflow-y-auto - Sidebar shrink-0, main flex-1 - tools.html simplified: shows "← Select a tool" when sidebar is present (tool list now lives in sidebar, not main content) - Removed "← Back to tools" link from tool editor (sidebar handles navigation) Try It panel polish: - Header shows tool name in monospace on the right - Send button: larger, rounded-lg, shadow, font-medium - Loading indicator: animated pulse dot + "running..." text --- src/internal/ui/handlers_tools.go | 219 +++++++++++++++++++++-- src/internal/ui/templates/layout.html | 52 +++++- src/internal/ui/templates/tool_edit.html | 16 +- src/internal/ui/templates/tools.html | 38 +--- 4 files changed, 266 insertions(+), 59 deletions(-) diff --git a/src/internal/ui/handlers_tools.go b/src/internal/ui/handlers_tools.go index b46c45b..187fb84 100644 --- a/src/internal/ui/handlers_tools.go +++ b/src/internal/ui/handlers_tools.go @@ -4,11 +4,14 @@ package ui import ( + "bytes" "context" + "encoding/json" "fmt" "html/template" "net/http" "sort" + "strings" "time" "github.com/factorly-dev/factorly/internal/config" @@ -61,10 +64,11 @@ func (s *Server) handleToolEdit(w http.ResponseWriter, r *http.Request) { } s.render(w, "tool_edit.html", map[string]any{ - "Title": name, - "Nav": "tools", - "Tool": toolView{Name: name, ToolConfig: tc}, - "Params": tc.Parameters, + "Title": name, + "Nav": "tools", + "ActiveTool": name, + "Tool": toolView{Name: name, ToolConfig: tc}, + "Params": tc.Parameters, }) } @@ -92,37 +96,196 @@ func (s *Server) handleToolTry(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if execErr != nil { - fmt.Fprintf(w, `
-
- Blocked - %dms -
-
%s
-
`, duration.Milliseconds(), execErr.Error()) + renderBlockedResponse(w, name, duration, execErr) return } output := "" status := "success" + statusIcon := "✓" if result != nil { output = result.Output if result.IsError() { status = "error" + statusIcon = "✗" } } + renderSuccessResponse(w, name, status, statusIcon, duration, output) +} + +func renderBlockedResponse(w http.ResponseWriter, tool string, dur time.Duration, err error) { + fmt.Fprintf(w, ` +
+
+
+ + blocked + %s +
+ %dms +
+
+
%s
+
+
`, tool, dur.Milliseconds(), template.HTMLEscapeString(err.Error())) +} + +func renderSuccessResponse(w http.ResponseWriter, tool, status, icon string, dur time.Duration, output string) { statusColor := "green" + statusBg := "green" + textColor := "green-300" if status == "error" { statusColor = "amber" + statusBg = "amber" + textColor = "amber-300" + } + + // Detect if output is JSON for formatting + isJSON := len(output) > 0 && (output[0] == '{' || output[0] == '[') + formattedOutput := template.HTMLEscapeString(output) + if isJSON { + // Pretty-print JSON before highlighting + var prettyBuf bytes.Buffer + if json.Indent(&prettyBuf, []byte(output), "", " ") == nil { + formattedOutput = formatJSONHTML(prettyBuf.String()) + } else { + formattedOutput = formatJSONHTML(output) + } } - fmt.Fprintf(w, `
-
- %s - %dms -
-
%s
-
`, statusColor, statusColor, statusColor, status, duration.Milliseconds(), template.HTMLEscapeString(output)) + tabID := fmt.Sprintf("tab-%d", time.Now().UnixNano()) + + fmt.Fprintf(w, ` +
+ +
+
+ %s + %s + %s +
+ %dms +
+ + +
+ + +
+ + +
+
+
%s
+
+ +
+
+ +`, + statusColor, + statusBg, + statusColor, statusColor, icon, + statusColor, status, + statusColor, tool, + dur.Milliseconds(), + tabID, tabID, + tabID, textColor, formattedOutput, + tabID, template.HTMLEscapeString(output), + ) +} + +// formatJSONHTML returns syntax-highlighted HTML for JSON content. +func formatJSONHTML(s string) string { + var out strings.Builder + inString := false + isKey := true + i := 0 + for i < len(s) { + ch := s[i] + switch { + case ch == '"' && !inString: + // Start of string + inString = true + j := i + 1 + for j < len(s) && s[j] != '"' { + if s[j] == '\\' { + j++ + } + j++ + } + if j < len(s) { + j++ // include closing quote + } + str := template.HTMLEscapeString(s[i:j]) + if isKey { + out.WriteString(`` + str + ``) + } else { + out.WriteString(`` + str + ``) + } + i = j + isKey = false + continue + case ch == ':' && !inString: + out.WriteString(`:`) + isKey = false + i++ + continue + case ch == ',' && !inString: + out.WriteString(`,`) + isKey = true + i++ + continue + case ch == '{' || ch == '}' || ch == '[' || ch == ']': + out.WriteString(`` + string(ch) + ``) + if ch == '{' { + isKey = true + } + i++ + continue + case (ch >= '0' && ch <= '9') || ch == '-': + j := i + for j < len(s) && (s[j] >= '0' && s[j] <= '9' || s[j] == '.' || s[j] == '-' || s[j] == 'e' || s[j] == 'E' || s[j] == '+') { + j++ + } + out.WriteString(`` + s[i:j] + ``) + i = j + continue + case ch == 't' && i+4 <= len(s) && s[i:i+4] == "true": + out.WriteString(`true`) + i += 4 + continue + case ch == 'f' && i+5 <= len(s) && s[i:i+5] == "false": + out.WriteString(`false`) + i += 5 + continue + case ch == 'n' && i+4 <= len(s) && s[i:i+4] == "null": + out.WriteString(`null`) + i += 4 + continue + default: + out.WriteByte(ch) + i++ + } + } + return out.String() } func (s *Server) handleToolFormPartial(w http.ResponseWriter, r *http.Request) { @@ -325,8 +488,28 @@ func (s *Server) render(w http.ResponseWriter, name string, data any) { http.Error(w, fmt.Sprintf("template %q not found", name), http.StatusInternalServerError) return } + + // Inject sidebar tools for tools-related pages + if m, ok := data.(map[string]any); ok && m["Nav"] == "tools" { + if _, hasSidebar := m["SidebarTools"]; !hasSidebar { + m["SidebarTools"] = s.getSidebarTools() + } + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := t.ExecuteTemplate(w, "layout", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } + +func (s *Server) getSidebarTools() []toolListItem { + var tools []toolListItem + for name, tc := range s.cfg.Tools { + tools = append(tools, toolListItem{ + Name: name, + Type: tc.Type, + }) + } + sort.Slice(tools, func(i, j int) bool { return tools[i].Name < tools[j].Name }) + return tools +} diff --git a/src/internal/ui/templates/layout.html b/src/internal/ui/templates/layout.html index d8cfdf2..519c79c 100644 --- a/src/internal/ui/templates/layout.html +++ b/src/internal/ui/templates/layout.html @@ -11,17 +11,59 @@ .htmx-indicator { display: none; } .htmx-request .htmx-indicator { display: inline; } .htmx-request button[type="submit"] { opacity: 0.5; pointer-events: none; } + .sidebar-item.active { background: #EEF2FF; border-right: 2px solid #6366F1; } + .sidebar-item:hover { background: #F9FAFB; } - - diff --git a/src/internal/ui/templates/workflow_edit.html b/src/internal/ui/templates/workflow_edit.html index 4b3d86b..47f9d96 100644 --- a/src/internal/ui/templates/workflow_edit.html +++ b/src/internal/ui/templates/workflow_edit.html @@ -1,12 +1,8 @@ {{define "content"}} - -

{{.Name}}

- workflow + workflow
+

Pipeline

+
-
+
{{range $i, $step := .Steps}} -
- {{inc $i}} - + {{range $.AvailableTools}} + + {{end}} + + {{else}} + switch + {{end}} +
+ +
+ + {{if $step.Store}} +
+ + +
+ {{else}} + + {{end}} + + {{if $step.Switch}} +
+ {{range $step.Switch}} +
+ + {{.Condition}} + + {{.Tool}} +
+ {{end}} +
+ {{end}} + + {{if $step.Params}} +
+ {{range $k, $v := $step.Params}} + {{$k}} {{end}} - - - +
+ {{end}} +
+ {{end}} + + {{if not .Steps}} +
+ No steps yet. Click "+ Add step" to start building.
{{end}}
- + {{if .Steps}} +
+ +
+ {{end}}
- -
-

Run

- -
- - {{if .Params}} -
- {{range .Params}} -
- - + +
+
+
+

Run

+ {{.Name}} +
+ + + + {{if .Params}} +
+ {{range .Params}} +
+ + +
+ {{end}}
+ {{else}} +

No parameters required.

{{end}} -
- {{else}} -

No parameters required.

- {{end}} - - running... - + + + executing steps... + + -
+
+
From 506d1882b586f42dec87b0a212b011a13da5cb46 Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Mon, 4 May 2026 16:50:05 -0400 Subject: [PATCH 05/65] Add Lucide icons, full tool config editing, compact workflow steps, and htmx-boost navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Icons: - Add `icon` template function with 12 Lucide SVG icons rendered inline (play, send, plus, trash, check, x, shield, terminal, globe, workflow, clock, lock) — no external font/CDN dependency - Add CSS rule `button svg, a svg { display: inline-block; vertical-align: middle }` to prevent SVGs breaking button text onto two lines - Replace ▶ unicode with {{icon "send"}} and {{icon "play"}} in tool and workflow buttons Tool editor — full config editing: - Add collapsible Parameters section: each param editable as a card with name, type dropdown, required checkbox, default, description; add/remove params dynamically; count shown in section header - Add collapsible Oversight section: deny list (comma-separated), confirm checkbox, rate limit input - Add collapsible Advanced section: timeout, max output bytes, compress hints - Add `joinList` template function for rendering string slices - Update handleToolSave to parse all new fields: parameters with name/type/required/default/description, shadow config from deny/confirm/rate_limit form fields, timeout, max_output, compress - Add `splitComma` helper for parsing comma-separated inputs - Add `strconv` import for max_output parsing Workflow steps — compact with full config: - Replace card pipeline with collapsible details/summary rows - Collapsed: step number + tool name + store badge + condition indicators (if/req pills) + remove button — all on one line - Expanded: full editing form for tool (dropdown), store, if condition, require condition, and params (key/value list with add/remove) - handleWorkflowSave updated to parse step_if_N, step_require_N, and step_param_key_N[]/step_param_val_N[] form arrays - New steps open expanded for immediate configuration Navigation and save UX: - Add hx-boost="true" on body — all link clicks and form submits use htmx automatically (AJAX page swap, no full browser refresh, URL still updates via history push, back button works) - Tool save form uses hx-post with inline "✓ Saved" confirmation instead of redirect + full page reload - Fix configPath not persisted: loadConfig() now sets the global configPath when auto-discovering via FindConfig(), so UI writes go to the correct file Visual polish: - Type-specific header previews: CLI terminal (dark bg, traffic lights, green $ command), REST method badge + URL bar, MCP purple hex with command - Colored type badges: CLI=gray, REST=blue, MCP=purple - Oversight indicator bar (amber) shown when shadow config exists - Parameter table with Name/Type/Required/Default columns - REST method + base URL on same row - Try It panel: uppercase tracking-wide header, full-width button, type shown next to param name --- src/internal/ui/server.go | 20 ++ src/internal/ui/templates/layout.html | 1 + src/internal/ui/templates/tool_edit.html | 231 +++++++++++------- src/internal/ui/templates/workflow_edit.html | 234 ++++++++++--------- 4 files changed, 289 insertions(+), 197 deletions(-) diff --git a/src/internal/ui/server.go b/src/internal/ui/server.go index 3d86f5f..1a9e4cd 100644 --- a/src/internal/ui/server.go +++ b/src/internal/ui/server.go @@ -128,6 +128,26 @@ func (s *Server) Start(addr string) error { func templateFuncs() template.FuncMap { return template.FuncMap{ "inc": func(i int) int { return i + 1 }, + "icon": func(name string) template.HTML { + icons := map[string]string{ + "play": ``, + "send": ``, + "plus": ``, + "trash": ``, + "check": ``, + "x": ``, + "shield": ``, + "terminal": ``, + "globe": ``, + "workflow": ``, + "clock": ``, + "lock": ``, + } + if svg, ok := icons[name]; ok { + return template.HTML(svg) + } + return "" + }, "joinArgs": func(args []string) string { var parts []string for _, a := range args { diff --git a/src/internal/ui/templates/layout.html b/src/internal/ui/templates/layout.html index 3e779cd..e188d13 100644 --- a/src/internal/ui/templates/layout.html +++ b/src/internal/ui/templates/layout.html @@ -13,6 +13,7 @@ .htmx-request button[type="submit"] { opacity: 0.5; pointer-events: none; } .sidebar-item.active { background: #EEF2FF; border-right: 2px solid #6366F1; } .sidebar-item:hover { background: #F9FAFB; } + button svg, a svg { display: inline-block; vertical-align: middle; } diff --git a/src/internal/ui/templates/tool_edit.html b/src/internal/ui/templates/tool_edit.html index ae1a107..638328f 100644 --- a/src/internal/ui/templates/tool_edit.html +++ b/src/internal/ui/templates/tool_edit.html @@ -3,103 +3,172 @@

{{.Tool.Name}}

- {{.Tool.Type}} + {{if eq .Tool.Type "cli"}} + CLI + {{else if eq .Tool.Type "rest"}} + REST + {{else if eq .Tool.Type "mcp"}} + MCP + {{end}}
-
- -
-

Configuration

+{{if .Tool.Shadow}} + +
+ + Oversight active: + {{shadowSummary .Tool.Shadow}} +
+{{end}} -
-
- - -
+
+ +
- {{if eq .Tool.Type "cli"}} -
- - + {{if eq .Tool.Type "cli"}} + +
+
+ + +
-
- - -

Space-separated. Quote args with spaces.

+
$ {{.Tool.Command}} {{joinArgs .Tool.Args}}
+
+ {{else if eq .Tool.Type "rest"}} + +
+
+ {{.Tool.Method}} + {{.Tool.BaseURL}}{{.Tool.Path}}
-
- - +
+ {{else if eq .Tool.Type "mcp"}} + +
+
+ + {{.Tool.Command}} {{joinArgs .Tool.Args}}
+
+ {{end}} - {{else if eq .Tool.Type "rest"}} -
- - -
-
- - -
-
- - -
-
- - -
+ +
+ +
+ + +
- {{else if eq .Tool.Type "mcp"}} -
- - -
-
- - -
- {{end}} + {{if eq .Tool.Type "cli"}} +
+ + +
+
+ + +
+
+ + +
- {{if .Tool.Shadow}} -
-

Oversight

- {{shadowSummary .Tool.Shadow}} -
- {{end}} + {{else if eq .Tool.Type "rest"}} +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
-
- -
- + {{else if eq .Tool.Type "mcp"}} +
+ + +
+
+ + +
+ {{end}} + + {{if .Params}} + +
+

Parameters

+
+ + + + + + + + + + + {{range .Params}} + + + + + + + {{end}} + +
NameTypeRequiredDefault
{{.Name}}{{if .Type}}{{.Type}}{{else}}string{{end}}{{if .Required}}yes{{else}}{{end}}{{if .Default}}{{.Default}}{{else}}{{end}}
+
+
+ {{end}} + +
+ +
+ +
-

Try it

+

Try it

{{.Tool.Name}}
@@ -112,12 +181,12 @@

Try it

{{range .Params}}
-
{{else}} -

No parameters required.

+

No parameters required.

{{end}} running... diff --git a/src/internal/ui/templates/workflow_edit.html b/src/internal/ui/templates/workflow_edit.html index 47f9d96..c98bd43 100644 --- a/src/internal/ui/templates/workflow_edit.html +++ b/src/internal/ui/templates/workflow_edit.html @@ -14,94 +14,93 @@

{{.Name}}

{{end}}
- +
-
-

Pipeline

- -
-
-
- {{range $i, $step := .Steps}} - - {{if $i}} - -
-
+
+ +
+ Steps +
- {{if $step.If}} -
- if: {{$step.If}} -
- {{else if $step.Require}} -
- require: {{$step.Require}} -
- {{end}} - {{end}} - -
-
-
- {{inc $i}} +
+ {{if not .Steps}} +
+ No steps. Click "+ Add" to start. +
+ {{end}} + + {{range $i, $step := .Steps}} +
+
+ + {{inc $i}} + + {{if $step.Tool}} - {{range $.AvailableTools}} {{end}} {{else}} - switch + switch {{end}} + + + {{if $step.Store}} +
+ + +
+ {{else}} + + + {{end}} + + +
- -
- {{if $step.Store}} -
- - -
- {{else}} - - {{end}} + {{if or $step.If $step.Require}} +
+ {{if $step.If}} + if: {{$step.If}} + {{end}} + {{if $step.Require}} + require: {{$step.Require}} + {{end}} +
+ {{end}} - {{if $step.Switch}} -
- {{range $step.Switch}} -
- - {{.Condition}} - - {{.Tool}} + {{if $step.Switch}} +
+ {{range $step.Switch}} +
+ {{.Condition}}{{.Tool}} +
+ {{end}}
{{end}} -
- {{end}} - {{if $step.Params}} -
- {{range $k, $v := $step.Params}} - {{$k}} + {{if $step.Params}} +
+ {{range $k, $v := $step.Params}} + {{$k}}={{$v}} + {{end}} +
{{end}}
{{end}}
- {{end}} - - {{if not .Steps}} -
- No steps yet. Click "+ Add step" to start building. -
- {{end}}
{{if .Steps}} -
- +
+
{{end}} @@ -111,8 +110,8 @@

Pipeline
-

Run

- {{.Name}} +

Run

+ {{.Name}}
Run

{{end}}
{{else}} -

No parameters required.

+

No parameters.

{{end}} - executing steps... + executing... @@ -154,71 +153,74 @@

Run

{{end}} From 89f7f3fe5afd8ecef57ed25e7998e7215923014c Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Mon, 4 May 2026 16:53:00 -0400 Subject: [PATCH 06/65] Fix tool save, full step config editing, and htmx navigation polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool save fix: - Persist resolved configPath in loadConfig() when auto-discovered via FindConfig() — fixes "open : no such file" error when saving tools from the UI without --config flag - Tool save form uses hx-post with inline "✓ Saved" flash instead of full-page redirect, targets #save-status span next to button Full tool config editing (handleToolSave): - Parse parameters from form: name, type, required, default, description per param (indexed param_name_N, param_type_N, etc.) - Parse shadow/oversight: deny (comma-separated), confirm checkbox, rate_limit field — constructs ShadowConfig or clears to nil - Parse advanced fields: timeout, max_output (strconv), compress (comma-separated via splitComma helper) - Add splitComma utility for parsing "all, json, logs" style inputs - Add strconv import Full workflow step config editing (handleWorkflowSave): - Parse step_if_N and step_require_N condition fields per step - Parse step_param_key_N[] and step_param_val_N[] arrays for per-step params, building map[string]string from paired arrays - Steps save with all fields: tool, store, if, require, params Template functions: - Add joinList (strings.Join with ", ") for rendering []string fields in form values (deny list, compress hints) Layout: - Add hx-boost="true" on body — all navigation uses htmx (AJAX page swap without full browser refresh, URL updates via history push, back button works) Tool editor template: - Collapsible Parameters section with add/remove param cards (name, type dropdown, required checkbox, default, description) - Collapsible Oversight section (deny, confirm, rate_limit) - Collapsible Advanced section (timeout, max_output, compress) - Type-specific previews: CLI terminal, REST method+URL, MCP hex - Save button with inline #save-status confirmation area - Form uses hx-post instead of method="POST" action="..." Workflow editor template: - Steps as collapsible details/summary: compact summary row (number + tool + store + condition pills), expanded form with tool dropdown, store, if, require, and params (key/value list with add/remove) --- src/cmd/factorly/main.go | 1 + src/internal/ui/handlers_tools.go | 75 +++- src/internal/ui/handlers_workflows.go | 31 +- src/internal/ui/server.go | 3 + src/internal/ui/templates/layout.html | 2 +- src/internal/ui/templates/tool_edit.html | 415 ++++++++++++------- src/internal/ui/templates/workflow_edit.html | 196 +++++---- 7 files changed, 463 insertions(+), 260 deletions(-) diff --git a/src/cmd/factorly/main.go b/src/cmd/factorly/main.go index ec97dc8..d9eb69a 100644 --- a/src/cmd/factorly/main.go +++ b/src/cmd/factorly/main.go @@ -503,6 +503,7 @@ func loadConfig() (*config.Config, *registry.Registry, error) { cfgPath := configPath if cfgPath == "" { cfgPath = config.FindConfig() + configPath = cfgPath // persist for UI/other commands that need the write path vlog("found config: %s", cfgPath) } else { vlog("using config: %s", cfgPath) diff --git a/src/internal/ui/handlers_tools.go b/src/internal/ui/handlers_tools.go index 187fb84..8a8f8d0 100644 --- a/src/internal/ui/handlers_tools.go +++ b/src/internal/ui/handlers_tools.go @@ -11,6 +11,7 @@ import ( "html/template" "net/http" "sort" + "strconv" "strings" "time" @@ -398,6 +399,21 @@ func (s *Server) handleToolSave(w http.ResponseWriter, r *http.Request) { } tc.Description = r.FormValue("description") + tc.Stdin = r.FormValue("stdin") + tc.Timeout = r.FormValue("timeout") + tc.Body = r.FormValue("body") + + if mo := r.FormValue("max_output"); mo != "" { + if n, err := strconv.Atoi(mo); err == nil { + tc.MaxOutput = n + } + } + if compress := r.FormValue("compress"); compress != "" { + tc.Compress = splitComma(compress) + } else { + tc.Compress = nil + } + switch tc.Type { case "cli": tc.Command = r.FormValue("command") @@ -406,16 +422,10 @@ func (s *Server) handleToolSave(w http.ResponseWriter, r *http.Request) { } else { tc.Args = nil } - if stdin := r.FormValue("stdin"); stdin != "" { - tc.Stdin = stdin - } case "rest": tc.BaseURL = r.FormValue("base_url") tc.Method = r.FormValue("method") tc.Path = r.FormValue("path") - if body := r.FormValue("body"); body != "" { - tc.Body = body - } case "mcp": tc.Command = r.FormValue("command") if args := r.FormValue("args"); args != "" { @@ -425,13 +435,50 @@ func (s *Server) handleToolSave(w http.ResponseWriter, r *http.Request) { } } + // Parse parameters + var params []config.ParamConfig + for i := 0; ; i++ { + pname := r.FormValue(fmt.Sprintf("param_name_%d", i)) + if pname == "" { + break + } + params = append(params, config.ParamConfig{ + Name: pname, + Type: r.FormValue(fmt.Sprintf("param_type_%d", i)), + Required: r.FormValue(fmt.Sprintf("param_required_%d", i)) == "on", + Default: r.FormValue(fmt.Sprintf("param_default_%d", i)), + Description: r.FormValue(fmt.Sprintf("param_desc_%d", i)), + }) + } + tc.Parameters = params + + // Parse shadow/oversight + deny := splitComma(r.FormValue("shadow_deny")) + confirmOn := r.FormValue("shadow_confirm") == "on" + rateLimit := r.FormValue("shadow_rate_limit") + if len(deny) > 0 || confirmOn || rateLimit != "" { + sc := &config.ShadowConfig{ + Deny: deny, + RateLimit: rateLimit, + } + if confirmOn { + sc.Confirm = true + } + tc.Shadow = sc + } else { + tc.Shadow = nil + } + if err := SaveTool(s.cfgPath, s.toolsDir, name, tc); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } s.cfg.Tools[name] = tc - http.Redirect(w, r, "/tools/"+name, http.StatusFound) + + // Return inline confirmation for htmx + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, `✓ Saved`) } func (s *Server) handleToolDelete(w http.ResponseWriter, r *http.Request) { @@ -482,6 +529,20 @@ func splitArgs(s string) []string { return args } +func splitComma(s string) []string { + if s == "" { + return nil + } + var result []string + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + return result +} + func (s *Server) render(w http.ResponseWriter, name string, data any) { t, ok := s.tmpls["templates/"+name] if !ok { diff --git a/src/internal/ui/handlers_workflows.go b/src/internal/ui/handlers_workflows.go index 86a9629..d9f0d99 100644 --- a/src/internal/ui/handlers_workflows.go +++ b/src/internal/ui/handlers_workflows.go @@ -66,9 +66,36 @@ func (s *Server) handleWorkflowSave(w http.ResponseWriter, r *http.Request) { break } store := r.FormValue(fmt.Sprintf("step_store_%d", i)) + ifCond := r.FormValue(fmt.Sprintf("step_if_%d", i)) + reqCond := r.FormValue(fmt.Sprintf("step_require_%d", i)) + + // Parse params (key[] and val[] arrays) + keys := r.Form[fmt.Sprintf("step_param_key_%d[]", i)] + vals := r.Form[fmt.Sprintf("step_param_val_%d[]", i)] + var params map[string]string + if len(keys) > 0 { + params = make(map[string]string) + for j, k := range keys { + if k == "" { + continue + } + v := "" + if j < len(vals) { + v = vals[j] + } + params[k] = v + } + if len(params) == 0 { + params = nil + } + } + steps = append(steps, config.StepConfig{ - Tool: tool, - Store: store, + Tool: tool, + Store: store, + If: ifCond, + Require: reqCond, + Params: params, }) } diff --git a/src/internal/ui/server.go b/src/internal/ui/server.go index 1a9e4cd..4f0337c 100644 --- a/src/internal/ui/server.go +++ b/src/internal/ui/server.go @@ -128,6 +128,9 @@ func (s *Server) Start(addr string) error { func templateFuncs() template.FuncMap { return template.FuncMap{ "inc": func(i int) int { return i + 1 }, + "joinList": func(items []string) string { + return strings.Join(items, ", ") + }, "icon": func(name string) template.HTML { icons := map[string]string{ "play": ``, diff --git a/src/internal/ui/templates/layout.html b/src/internal/ui/templates/layout.html index e188d13..65f057c 100644 --- a/src/internal/ui/templates/layout.html +++ b/src/internal/ui/templates/layout.html @@ -16,7 +16,7 @@ button svg, a svg { display: inline-block; vertical-align: middle; } - +
{{if .Tool.Shadow}} -
- - Oversight active: + {{icon "shield"}} + Oversight: {{shadowSummary .Tool.Shadow}}
{{end}}
-
+
+
- {{if eq .Tool.Type "cli"}} - -
-
- - - -
-
$ {{.Tool.Command}} {{joinArgs .Tool.Args}}
-
- {{else if eq .Tool.Type "rest"}} - -
-
- {{.Tool.Method}} - {{.Tool.BaseURL}}{{.Tool.Path}} + {{if eq .Tool.Type "cli"}} + +
+
+ + + +
+
$ {{.Tool.Command}} {{joinArgs .Tool.Args}}
-
- {{else if eq .Tool.Type "mcp"}} - -
-
- - {{.Tool.Command}} {{joinArgs .Tool.Args}} + {{else if eq .Tool.Type "rest"}} + +
+
+ {{.Tool.Method}} + {{.Tool.BaseURL}}{{.Tool.Path}} +
-
- {{end}} + {{end}} - -
- -
- - +
+ +
+ Configuration
+
+
+ + +
- {{if eq .Tool.Type "cli"}} -
- - -
-
- - -
-
- - -
+ {{if eq .Tool.Type "cli"}} +
+ + +
+
+ + +
+
+ + +
- {{else if eq .Tool.Type "rest"}} -
-
- - + {{else if eq .Tool.Type "rest"}} +
+
+ + +
+
+ + +
-
- - +
+ + +
+
+ +
-
-
- - -
-
- - -
- {{else if eq .Tool.Type "mcp"}} -
- - -
-
- - + {{else if eq .Tool.Type "mcp"}} +
+ + +
+
+ + +
+ {{end}}
- {{end}} - {{if .Params}} - -
-

Parameters

-
- - - - - - - - - - - {{range .Params}} - - - - - - - {{end}} - -
NameTypeRequiredDefault
{{.Name}}{{if .Type}}{{.Type}}{{else}}string{{end}}{{if .Required}}yes{{else}}{{end}}{{if .Default}}{{.Default}}{{else}}{{end}}
+ +
+ + Parameters {{if .Params}}({{len .Params}}){{end}} + + Add + +
+ {{range $i, $p := .Params}} +
+
+
+ + + +
+ +
+
+ + +
+
+ {{end}}
-
- {{end}} + -
- -
- -
+ +
+ + Oversight + +
+
+ + +
+
+ +
+
+ + +
+
+
+ + +
+ + Advanced + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ +
+ + +
+
-
-
-

Try it

- {{.Tool.Name}} -
+
+
+
+

Try it

+ {{.Tool.Name}} +
+ +
- - - {{if .Params}} -
- {{range .Params}} -
- - {{if .Description}} -

{{.Description}}

+ {{if .Params}} +
+ {{range .Params}} +
+ + {{if .Description}} +

{{.Description}}

+ {{end}} + +
{{end}} -
+ {{else}} +

No parameters.

{{end}} -
- {{else}} -

No parameters required.

- {{end}} - - - running... - - + + + running... + + -
+
+
+ + {{end}} diff --git a/src/internal/ui/templates/workflow_edit.html b/src/internal/ui/templates/workflow_edit.html index c98bd43..07473d8 100644 --- a/src/internal/ui/templates/workflow_edit.html +++ b/src/internal/ui/templates/workflow_edit.html @@ -18,7 +18,6 @@

{{.Name}}

-
Steps @@ -32,68 +31,77 @@

{{.Name}}

{{end}} {{range $i, $step := .Steps}} -
-
- +
+ {{inc $i}} - {{if $step.Tool}} - + {{$step.Tool}} {{else}} switch {{end}} - {{if $step.Store}} -
- - -
- {{else}} - - + + {{$step.Store}} {{end}} - - -
+ {{if $step.If}}if{{end}} + {{if $step.Require}}req{{end}} + + + + +
+ +
+ + +
- {{if or $step.If $step.Require}} -
- {{if $step.If}} - if: {{$step.If}} - {{end}} - {{if $step.Require}} - require: {{$step.Require}} - {{end}} -
- {{end}} + +
+ + +
- {{if $step.Switch}} -
- {{range $step.Switch}} -
- {{.Condition}}{{.Tool}} + +
+ +
- {{end}} -
- {{end}} - {{if $step.Params}} -
- {{range $k, $v := $step.Params}} - {{$k}}={{$v}} - {{end}} + +
+ + +
+ + +
+ +
+ {{range $k, $v := $step.Params}} +
+ + + +
+ {{end}} +
+ +
- {{end}} -
+ {{end}}
@@ -138,7 +146,7 @@

Run

{{end}} @@ -157,69 +165,79 @@

Run

const rows = list.querySelectorAll('.step-row'); const idx = rows.length; - // Remove empty state const empty = document.getElementById('empty-state'); if (empty) empty.remove(); - // Get tool options let options = ''; const firstSelect = list.querySelector('.step-tool'); - if (firstSelect) { - options = firstSelect.innerHTML; - } - - const row = document.createElement('div'); - row.className = 'step-row border-b border-gray-100 last:border-b-0 hover:bg-gray-50 transition-colors'; - row.dataset.index = idx; - row.innerHTML = ` -
+ if (firstSelect) options = firstSelect.innerHTML; + + const details = document.createElement('details'); + details.className = 'step-row border-b border-gray-100 last:border-b-0'; + details.dataset.index = idx; + details.open = true; + details.innerHTML = ` + ${idx + 1} - - - - + new step + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
`; - list.appendChild(row); + list.appendChild(details); } -function showStore(btn, idx) { - const row = btn.closest('.step-row'); - // Replace hidden input + button with visible store input - const hidden = row.querySelector('input[type="hidden"][name="step_store_' + idx + '"]'); - if (hidden) hidden.remove(); - btn.replaceWith(createStoreInput(idx)); -} - -function createStoreInput(idx) { - const wrapper = document.createElement('div'); - wrapper.className = 'flex items-center gap-1 shrink-0'; - wrapper.innerHTML = ` - - +function addParam(btn, stepIdx) { + const container = btn.previousElementSibling; + const row = document.createElement('div'); + row.className = 'param-row flex gap-1'; + row.innerHTML = ` + + + `; - return wrapper; + container.appendChild(row); } function removeStep(btn) { const row = btn.closest('.step-row'); row.remove(); - // Renumber const rows = document.querySelectorAll('.step-row'); rows.forEach((r, i) => { r.dataset.index = i; const num = r.querySelector('.step-num'); if (num) num.textContent = i + 1; - const sel = r.querySelector('.step-tool'); - if (sel) sel.name = 'step_tool_' + i; - const store = r.querySelector('.step-store'); - if (store) store.name = 'step_store_' + i; - const hidden = r.querySelector('input[type="hidden"]'); - if (hidden && hidden.name.startsWith('step_store_')) hidden.name = 'step_store_' + i; + r.querySelectorAll('[name^="step_tool_"]').forEach(el => el.name = 'step_tool_' + i); + r.querySelectorAll('[name^="step_store_"]').forEach(el => el.name = 'step_store_' + i); + r.querySelectorAll('[name^="step_if_"]').forEach(el => el.name = 'step_if_' + i); + r.querySelectorAll('[name^="step_require_"]').forEach(el => el.name = 'step_require_' + i); + r.querySelectorAll('[name^="step_param_key_"]').forEach(el => el.name = 'step_param_key_' + i + '[]'); + r.querySelectorAll('[name^="step_param_val_"]').forEach(el => el.name = 'step_param_val_' + i + '[]'); }); if (rows.length === 0) { - const list = document.getElementById('steps-list'); - list.innerHTML = '
No steps. Click "+ Add" to start.
'; + document.getElementById('steps-list').innerHTML = '
No steps. Click "+ Add" to start.
'; } } From fc85b471800ef16e57d9ab6ce54fa66c964de6e6 Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Mon, 4 May 2026 17:16:46 -0400 Subject: [PATCH 07/65] Add host validation and per-run token auth to UI server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harden the localhost UI server against cross-origin attacks: Loopback binding: - Bind to 127.0.0.1: explicitly instead of localhost (avoids potential IPv6 resolution or hostname override) - Open browser to http://localhost:/?token= Host header validation: - Middleware rejects requests where Host header (port stripped) is not localhost or 127.0.0.1 - Returns 403 Forbidden for unexpected hosts - Prevents DNS rebinding attacks where a malicious domain resolves to 127.0.0.1 and attempts to reach the local UI server Per-run token authentication: - generateNonce() creates a 32-char crypto/rand hex token unique to each process run — never persisted to disk - Browser opened with ?token= in URL - First request with valid token: sets HttpOnly SameSite=Strict cookie (factorly_session) and redirects to strip token from the address bar - Subsequent requests validated via cookie only - Static assets (/static/*) exempt from token check (no sensitive data, needed for CSS/JS loading before cookie is set) - Requests without valid cookie or token get 401 Unauthorized - SameSite=Strict cookie prevents cross-origin sites from sending it, blocking CSRF-style attacks against the local server Handler extraction: - Add Server.Handler() method returning the inner http.Handler so ui_cmd.go can wrap it with security middleware before serving - ui_cmd now uses http.ListenAndServe directly with the wrapped handler chain: hostValidation → tokenValidation → UI handler --- src/cmd/factorly/ui_cmd.go | 92 +++++++++++++++++++++++++++++++++++--- src/internal/ui/server.go | 5 +++ 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/src/cmd/factorly/ui_cmd.go b/src/cmd/factorly/ui_cmd.go index ed5df76..ea926f5 100644 --- a/src/cmd/factorly/ui_cmd.go +++ b/src/cmd/factorly/ui_cmd.go @@ -4,9 +4,13 @@ package main import ( + "crypto/rand" + "encoding/hex" "fmt" + "net/http" "os/exec" "runtime" + "strings" "github.com/factorly-dev/factorly/internal/ui" "github.com/factorly-dev/factorly/internal/vault" @@ -15,6 +19,8 @@ import ( var uiPort int +const uiHost = "localhost" + var uiCmd = &cobra.Command{ Use: "ui", Short: "Open the Factorly web UI", @@ -42,8 +48,6 @@ func runUI(cmd *cobra.Command, args []string) error { var vaultBackend vault.Backend vaultBackend, _ = getCachedVault() - addr := fmt.Sprintf("localhost:%d", uiPort) - srv, err := ui.New(ui.Options{ Config: cfg, CfgPath: configPath, @@ -56,10 +60,88 @@ func runUI(cmd *cobra.Command, args []string) error { return err } - // Open browser - go openBrowser(fmt.Sprintf("http://%s", addr)) + // Generate per-run nonce token + token := generateNonce() + + // Bind to loopback only + addr := fmt.Sprintf("127.0.0.1:%d", uiPort) + url := fmt.Sprintf("http://localhost:%d/?token=%s", uiPort, token) + + fmt.Fprintf(cmd.ErrOrStderr(), "Factorly UI running at %s\n", url) + + // Open browser with token + go openBrowser(url) + + // Wrap handler with host validation + token check + handler := hostValidation(tokenValidation(srv.Handler(), token)) + + return http.ListenAndServe(addr, handler) +} + +// hostValidation rejects requests with unexpected Host headers. +func hostValidation(next http.Handler) http.Handler { + allowed := map[string]bool{ + "localhost": true, + "127.0.0.1": true, + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := r.Host + // Strip port + if i := strings.LastIndex(host, ":"); i != -1 { + host = host[:i] + } + if !allowed[host] { + http.Error(w, "forbidden: invalid host", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// tokenValidation requires a valid token on the first request (sets a cookie), +// then validates the cookie on subsequent requests. Static assets are exempt. +func tokenValidation(next http.Handler, token string) http.Handler { + const cookieName = "factorly_session" + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Static assets don't need auth + if strings.HasPrefix(r.URL.Path, "/static/") { + next.ServeHTTP(w, r) + return + } + + // Check cookie first + if cookie, err := r.Cookie(cookieName); err == nil && cookie.Value == token { + next.ServeHTTP(w, r) + return + } + + // Check token query param (initial page load from browser open) + if r.URL.Query().Get("token") == token { + // Set session cookie and redirect to clean URL + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + // Redirect to strip token from URL + clean := r.URL + q := clean.Query() + q.Del("token") + clean.RawQuery = q.Encode() + http.Redirect(w, r, clean.String(), http.StatusFound) + return + } + + http.Error(w, "unauthorized", http.StatusUnauthorized) + }) +} - return srv.Start(addr) +func generateNonce() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) } func openBrowser(url string) { diff --git a/src/internal/ui/server.go b/src/internal/ui/server.go index 4f0337c..93af8c1 100644 --- a/src/internal/ui/server.go +++ b/src/internal/ui/server.go @@ -119,6 +119,11 @@ func (s *Server) routes() { s.mux.HandleFunc("DELETE /vault/{key}", s.handleVaultDelete) } +// Handler returns the HTTP handler for the UI (for wrapping with middleware). +func (s *Server) Handler() http.Handler { + return s.mux +} + // Start begins serving the UI. func (s *Server) Start(addr string) error { fmt.Fprintf(os.Stderr, "Factorly UI running at http://%s\n", addr) From 2cadd6c4ac1f9e95c224e71594546b69d67b3be5 Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Mon, 4 May 2026 17:54:31 -0400 Subject: [PATCH 08/65] Update layout to include factorly logo. Add tool edit to include auth --- src/internal/ui/templates/layout.html | 7 +++++-- src/internal/ui/templates/tool_edit.html | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/internal/ui/templates/layout.html b/src/internal/ui/templates/layout.html index 65f057c..f5cf1c1 100644 --- a/src/internal/ui/templates/layout.html +++ b/src/internal/ui/templates/layout.html @@ -18,8 +18,11 @@ -
+ {{if .Tool.Auth}} +
+ +
+ {{.Tool.Auth.Type}} + + {{if eq .Tool.Auth.Type "bearer"}}{{.Tool.Auth.Token}} + {{else if eq .Tool.Auth.Type "header"}}{{.Tool.Auth.Header}}: {{.Tool.Auth.Value}} + {{else if eq .Tool.Auth.Type "oauth"}}provider + {{else}}configured{{end}} + +
+
+ {{end}} +
+

Regex pattern→replacement, one per line. Use → (arrow) as separator.

+
+
+ +
From 241b9d2332bc5ee1835ccd05ac4960f3f17b1dc6 Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Thu, 7 May 2026 16:51:11 -0400 Subject: [PATCH 32/65] Add hidden tool visibility toggle, graceful vault fallback, runtime template install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "hidden" flag for tools that removes them from MCP listing and CLI tools output while keeping them callable directly and by workflows. Make vault initialization non-fatal so the app starts even without a vault file. Register template-installed tools at runtime. Hidden tool visibility: - Add hidden field to ToolConfig (yaml: hidden), registry Tool, and toolListItem for sidebar rendering - Registry.ListVisible() filters out hidden tools — used by MCP server and CLI tools list; Registry.List() still returns all (for workflows) - Sidebar shows hidden tools dimmed (opacity-50) with ⊘ indicator - Tool detail page shows info banner when hidden - Checkbox in Advanced section to toggle hidden on/off - Parsed and saved in tool save handler Graceful vault fallback: - initResolver no longer fatally errors when vault file is missing - Uses getCachedVault() which tries project vault, then global vault via openFallbackVault() — previously skipped global due to pre-check with os.Stat on project path only - When vault is unavailable, logs warning and continues — vault refs pass through unresolved, tools work until they need a secret Template install runtime registration: - handleTemplateInstall now parses installed tool YAML and calls registerTool() for each tool, updating config, registry, and provider - Installed template tools appear immediately without restart --- src/cmd/factorly/main.go | 32 +++++++++--------------- src/internal/config/config.go | 1 + src/internal/registry/registry.go | 16 ++++++++++++ src/internal/server/mcp.go | 2 +- src/internal/ui/handlers_templates.go | 10 ++++++++ src/internal/ui/handlers_tools.go | 13 +++++++--- src/internal/ui/server.go | 1 + src/internal/ui/templates/layout.html | 8 +++--- src/internal/ui/templates/tool_edit.html | 13 ++++++++++ 9 files changed, 67 insertions(+), 29 deletions(-) diff --git a/src/cmd/factorly/main.go b/src/cmd/factorly/main.go index d9eb69a..b7b0cd7 100644 --- a/src/cmd/factorly/main.go +++ b/src/cmd/factorly/main.go @@ -185,7 +185,7 @@ func runToolsList(cmd *cobra.Command, args []string) error { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "NAME\tTYPE\tDESCRIPTION\tPARAMETERS") - for _, t := range reg.List() { + for _, t := range reg.ListVisible() { if shadowPolicy != nil && shadowPolicy.IsDenied(t.Name) { continue } @@ -545,6 +545,7 @@ func loadConfig() (*config.Config, *registry.Registry, error) { Name: name, Type: toolCfg.Type, Description: toolCfg.Description, + Hidden: toolCfg.Hidden, Parameters: params, ProviderKey: toolCfg.Type, MaxOutput: toolCfg.MaxOutput, @@ -928,27 +929,18 @@ func initResolver(cfg *config.Config) (*vault.Resolver, error) { vlog("registered external vault backend: %s", name) } - // Try to open local vault - // resolveVaultPath() respects --vault-path, --global, FACTORLY_VAULT_PATH, - // project vault, and global vault — in that priority order. - resolvedPath := resolveVaultPath() - if _, err := os.Stat(resolvedPath); err == nil { - var backend vault.Backend - var openErr error - - backend, openErr = getCachedVault() - if openErr != nil { - if len(cfg.VaultBackends) > 0 { - vlog("local vault failed to open: %v (external backends available)", openErr) - } else { - return nil, fmt.Errorf("vault required but failed to open: %w", openErr) - } + // Try to open vault (project vault, global vault, or fallback) + backend, openErr := getCachedVault() + if openErr != nil { + if len(cfg.VaultBackends) > 0 { + vlog("local vault failed to open: %v (external backends available)", openErr) } else { - resolver.Register("vault", backend) - vlog("vault opened successfully") + vlog("warning: vault references found but vault unavailable: %v — refs will not be resolved", openErr) + return resolver, nil } - } else if len(cfg.VaultBackends) == 0 { - return nil, fmt.Errorf("vault required but no vault found at %s (run 'factorly vault set' to create one)", resolvedPath) + } else { + resolver.Register("vault", backend) + vlog("vault opened successfully") } return resolver, nil diff --git a/src/internal/config/config.go b/src/internal/config/config.go index 68e60a4..3e2ff09 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -45,6 +45,7 @@ type OAuthProviderConfig struct { type ToolConfig struct { Type string `yaml:"type"` Description string `yaml:"description"` + Hidden bool `yaml:"hidden,omitempty"` // hidden from MCP listing and tools list, but callable directly and by workflows Command string `yaml:"command,omitempty"` Args []string `yaml:"args,omitempty"` Stdin string `yaml:"stdin,omitempty"` diff --git a/src/internal/registry/registry.go b/src/internal/registry/registry.go index 203dc81..6923bf4 100644 --- a/src/internal/registry/registry.go +++ b/src/internal/registry/registry.go @@ -29,6 +29,7 @@ type Tool struct { Name string Type string Description string + Hidden bool // hidden from MCP listing and tools list Parameters []Parameter ProviderKey string MaxOutput int @@ -74,6 +75,7 @@ func (r *Registry) Get(name string) (*Tool, error) { return t, nil } +// List returns all tools including hidden ones (for internal use like workflows). func (r *Registry) List() []*Tool { tools := make([]*Tool, 0, len(r.tools)) for _, t := range r.tools { @@ -84,3 +86,17 @@ func (r *Registry) List() []*Tool { }) return tools } + +// ListVisible returns only non-hidden tools (for MCP listing and CLI tools list). +func (r *Registry) ListVisible() []*Tool { + tools := make([]*Tool, 0, len(r.tools)) + for _, t := range r.tools { + if !t.Hidden { + tools = append(tools, t) + } + } + sort.Slice(tools, func(i, j int) bool { + return tools[i].Name < tools[j].Name + }) + return tools +} diff --git a/src/internal/server/mcp.go b/src/internal/server/mcp.go index 04dc196..c149f9c 100644 --- a/src/internal/server/mcp.go +++ b/src/internal/server/mcp.go @@ -104,7 +104,7 @@ func New(reg *registry.Registry, p *proxy.Proxy, agentReg ...*agent.Registry) *s server.WithHooks(hooks), ) - for _, tool := range reg.List() { + for _, tool := range reg.ListVisible() { // Hide denied tools from MCP clients if p.Shadow() != nil && p.Shadow().IsDenied(tool.Name) { continue diff --git a/src/internal/ui/handlers_templates.go b/src/internal/ui/handlers_templates.go index 12f12d0..3787200 100644 --- a/src/internal/ui/handlers_templates.go +++ b/src/internal/ui/handlers_templates.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" + "github.com/factorly-dev/factorly/internal/config" "github.com/factorly-dev/factorly/internal/templates" "gopkg.in/yaml.v3" ) @@ -97,5 +98,14 @@ func (s *Server) handleTemplateInstall(w http.ResponseWriter, r *http.Request) { } } + // Parse installed tools and register at runtime + var installedTools map[string]config.ToolConfig + if err := yaml.Unmarshal([]byte(toolYAML), &installedTools); err == nil { + for toolName, tc := range installedTools { + s.cfg.Tools[toolName] = tc + s.registerTool(toolName, tc) + } + } + http.Redirect(w, r, "/tools", http.StatusFound) } diff --git a/src/internal/ui/handlers_tools.go b/src/internal/ui/handlers_tools.go index 9ad2d30..b0a9759 100644 --- a/src/internal/ui/handlers_tools.go +++ b/src/internal/ui/handlers_tools.go @@ -24,6 +24,7 @@ type toolListItem struct { Type string Description string Shadow *config.ShadowConfig + Hidden bool } func (s *Server) handleToolsList(w http.ResponseWriter, r *http.Request) { @@ -37,6 +38,7 @@ func (s *Server) handleToolsList(w http.ResponseWriter, r *http.Request) { Type: tc.Type, Description: tc.Description, Shadow: tc.Shadow, + Hidden: tc.Hidden, }) } sort.Slice(tools, func(i, j int) bool { return tools[i].Name < tools[j].Name }) @@ -526,6 +528,7 @@ func (s *Server) handleToolSave(w http.ResponseWriter, r *http.Request) { } tc.Description = r.FormValue("description") + tc.Hidden = r.FormValue("hidden") == "on" tc.Stdin = r.FormValue("stdin") tc.Timeout = r.FormValue("timeout") tc.Body = r.FormValue("body") @@ -836,8 +839,9 @@ func (s *Server) getSidebarTools() []toolListItem { continue } tools = append(tools, toolListItem{ - Name: name, - Type: tc.Type, + Name: name, + Type: tc.Type, + Hidden: tc.Hidden, }) } sort.Slice(tools, func(i, j int) bool { return tools[i].Name < tools[j].Name }) @@ -851,8 +855,9 @@ func (s *Server) getSidebarWorkflows() []toolListItem { continue } workflows = append(workflows, toolListItem{ - Name: name, - Type: tc.Type, + Name: name, + Type: tc.Type, + Hidden: tc.Hidden, }) } sort.Slice(workflows, func(i, j int) bool { return workflows[i].Name < workflows[j].Name }) diff --git a/src/internal/ui/server.go b/src/internal/ui/server.go index c54f640..5fabe61 100644 --- a/src/internal/ui/server.go +++ b/src/internal/ui/server.go @@ -226,6 +226,7 @@ func (s *Server) registerTool(name string, tc config.ToolConfig) { Name: name, Type: tc.Type, Description: tc.Description, + Hidden: tc.Hidden, Parameters: params, ProviderKey: tc.Type, MaxOutput: tc.MaxOutput, diff --git a/src/internal/ui/templates/layout.html b/src/internal/ui/templates/layout.html index c61dd0c..33a1f7f 100644 --- a/src/internal/ui/templates/layout.html +++ b/src/internal/ui/templates/layout.html @@ -60,9 +60,9 @@ @@ -81,8 +81,8 @@ diff --git a/src/internal/ui/templates/tool_edit.html b/src/internal/ui/templates/tool_edit.html index a15fa75..56b997e 100644 --- a/src/internal/ui/templates/tool_edit.html +++ b/src/internal/ui/templates/tool_edit.html @@ -46,6 +46,13 @@

{{.Tool.Name}}

class="text-red-400 hover:text-red-600 text-sm">Delete
+{{if .Tool.Hidden}} +
+ {{icon "lock"}} + Hidden from MCP listing and tools list. Still callable directly and by workflows. +
+{{end}} + {{if .Tool.Shadow}}
{{icon "shield"}} @@ -320,6 +327,12 @@

{{.Tool.Name}}

+
From 29cb5ecbd27b7950f1c4794f3d5600d544817c7e Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Thu, 7 May 2026 17:31:24 -0400 Subject: [PATCH 33/65] Fix duplicate tool errors from loose YAML files in config directory SaveTool and DeleteTool now scan and clean loose YAML files in the config directory (e.g., .factorly/echo.yaml) in addition to the main config file and tools_dir. Prevents duplicates when a tool exists in a multi-tool loose file AND another location. Root cause: config loading merges tools from three sources (factorly.yaml inline, loose .factorly/*.yaml files, and .factorly/tools/), but save and delete only cleaned two of them. A tool like "env" defined in both .factorly/echo.yaml (multi-tool loose file) and .factorly/tools/env.yaml caused a duplicate error on restart. Changes: - Add removeToolFromLooseFiles(): scans all YAML files in the config directory (excluding factorly.yaml itself) for the tool name and removes it via removeToolFromFile() - Add findToolInFile(): checks if a YAML file contains a named tool - SaveTool calls removeToolFromLooseFiles before writing to prevent dupes - DeleteTool calls removeToolFromLooseFiles to clean all locations Tests: - TestDeleteTool_RemovesFromLooseFiles: tool in loose multi-tool file AND tools_dir, delete removes from both, keeps sibling tools - TestSaveTool_CleansLooseFileDuplicates: tool in loose file, save cleans it and writes to tools_dir - TestDeleteTool_LooseFileOnly: tool only in loose file, deletes file when empty --- src/internal/ui/yamlio.go | 52 ++++++++++++++++- src/internal/ui/yamlio_test.go | 100 +++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 3 deletions(-) diff --git a/src/internal/ui/yamlio.go b/src/internal/ui/yamlio.go index 5d2715d..19a6db5 100644 --- a/src/internal/ui/yamlio.go +++ b/src/internal/ui/yamlio.go @@ -17,23 +17,69 @@ import ( // individual file and removes any inline definition to avoid duplicates. // Otherwise appends/updates in the main config file. func SaveTool(cfgPath, toolsDir, name string, tc config.ToolConfig) error { + // Always clean from other locations to prevent duplicates + _ = deleteToolFromConfig(cfgPath, name) + removeToolFromLooseFiles(cfgPath, name) if toolsDir != "" { - // Remove from inline config to prevent duplicate errors - _ = deleteToolFromConfig(cfgPath, name) return saveToolToDir(toolsDir, name, tc) } return saveToolToConfig(cfgPath, name, tc) } -// DeleteTool removes a tool from disk (both inline and dir file). +// DeleteTool removes a tool from disk (inline, loose files, and dir file). func DeleteTool(cfgPath, toolsDir, name string) error { _ = deleteToolFromConfig(cfgPath, name) + removeToolFromLooseFiles(cfgPath, name) if toolsDir != "" { return deleteToolFromDir(toolsDir, name) } return nil } +// removeToolFromLooseFiles removes a tool from any loose YAML files in the +// config directory (e.g., .factorly/*.yaml other than factorly.yaml). +func removeToolFromLooseFiles(cfgPath, name string) { + configDir := filepath.Dir(cfgPath) + configBase := filepath.Base(cfgPath) + + entries, err := os.ReadDir(configDir) + if err != nil { + return + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + entryName := entry.Name() + // Skip the main config file and non-YAML files + if entryName == configBase { + continue + } + ext := filepath.Ext(entryName) + if ext != ".yaml" && ext != ".yml" { + continue + } + path := filepath.Join(configDir, entryName) + if findToolInFile(path, name) { + _ = removeToolFromFile(path, name) + } + } +} + +// findToolInFile checks if a YAML file contains a tool with the given name. +func findToolInFile(path, name string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + var tools map[string]any + if err := yaml.Unmarshal(data, &tools); err != nil { + return false + } + _, exists := tools[name] + return exists +} + func saveToolToDir(toolsDir, name string, tc config.ToolConfig) error { if err := os.MkdirAll(toolsDir, 0o755); err != nil { return err diff --git a/src/internal/ui/yamlio_test.go b/src/internal/ui/yamlio_test.go index 494562d..8addbb5 100644 --- a/src/internal/ui/yamlio_test.go +++ b/src/internal/ui/yamlio_test.go @@ -561,3 +561,103 @@ func TestFindToolInDir(t *testing.T) { t.Errorf("should not find nonexistent, got %s", path) } } + +func TestDeleteTool_RemovesFromLooseFiles(t *testing.T) { + // Simulate .factorly/ with a loose multi-tool file and a tools/ dir + dir := t.TempDir() + cfgPath := filepath.Join(dir, "factorly.yaml") + toolsDir := filepath.Join(dir, "tools") + _ = os.MkdirAll(toolsDir, 0o755) + + // Main config with empty tools + _ = os.WriteFile(cfgPath, []byte("tools: {}\n"), 0o644) + + // Loose file in config dir with multiple tools (like echo.yaml containing echo + env) + looseFile := filepath.Join(dir, "multi.yaml") + _ = os.WriteFile(looseFile, []byte("echo:\n type: cli\n command: echo\nenv:\n type: cli\n command: env\n"), 0o644) + + // Same tool also in tools dir + _ = os.WriteFile(filepath.Join(toolsDir, "env.yaml"), []byte("env:\n type: cli\n command: env\n"), 0o644) + + // Delete should remove from both locations + if err := DeleteTool(cfgPath, toolsDir, "env"); err != nil { + t.Fatal(err) + } + + // Verify removed from loose file + data, err := os.ReadFile(looseFile) + if err != nil { + t.Fatal(err) + } + content := string(data) + if strings.Contains(content, "env:") { + t.Error("env should be removed from loose file") + } + if !strings.Contains(content, "echo:") { + t.Error("echo should still be in loose file") + } + + // Verify removed from tools dir + if _, err := os.Stat(filepath.Join(toolsDir, "env.yaml")); err == nil { + t.Error("env.yaml should be deleted from tools dir") + } +} + +func TestSaveTool_CleansLooseFileDuplicates(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "factorly.yaml") + toolsDir := filepath.Join(dir, "tools") + _ = os.MkdirAll(toolsDir, 0o755) + + _ = os.WriteFile(cfgPath, []byte("tools: {}\n"), 0o644) + + // Tool exists in a loose file + looseFile := filepath.Join(dir, "misc.yaml") + _ = os.WriteFile(looseFile, []byte("my.tool:\n type: cli\n command: old\n"), 0o644) + + // Save should remove from loose file and write to tools dir + tc := config.ToolConfig{ + Type: "cli", + Command: "new", + } + if err := SaveTool(cfgPath, toolsDir, "my.tool", tc); err != nil { + t.Fatal(err) + } + + // Verify removed from loose file (file should be deleted since it's now empty) + if _, err := os.Stat(looseFile); err == nil { + data, _ := os.ReadFile(looseFile) + if strings.Contains(string(data), "my.tool") { + t.Error("my.tool should be removed from loose file") + } + } + + // Verify saved in tools dir + saved := findToolInDir(toolsDir, "my.tool") + if saved == "" { + t.Error("my.tool should be in tools dir") + } +} + +func TestDeleteTool_LooseFileOnly(t *testing.T) { + // Tool only exists in a loose file, not in tools_dir or factorly.yaml + dir := t.TempDir() + cfgPath := filepath.Join(dir, "factorly.yaml") + toolsDir := filepath.Join(dir, "tools") + _ = os.MkdirAll(toolsDir, 0o755) + + _ = os.WriteFile(cfgPath, []byte("tools: {}\n"), 0o644) + _ = os.WriteFile(filepath.Join(dir, "standalone.yaml"), []byte("lonely:\n type: cli\n command: echo\n"), 0o644) + + if err := DeleteTool(cfgPath, toolsDir, "lonely"); err != nil { + t.Fatal(err) + } + + // File should be deleted (was the only tool) + if _, err := os.Stat(filepath.Join(dir, "standalone.yaml")); err == nil { + data, _ := os.ReadFile(filepath.Join(dir, "standalone.yaml")) + if strings.Contains(string(data), "lonely") { + t.Error("lonely should be removed") + } + } +} From 84d815b26bd248ea85fb25abdc805dc03226955a Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Thu, 7 May 2026 19:53:24 -0400 Subject: [PATCH 34/65] Fix auth vault fallback, template OAuth install, empty query params, provider cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiple fixes across auth, REST provider, and template installation. Auth commands use global vault fallback: - auth login/status/logout now use getCachedVault() (FallbackBackend) instead of getCachedLocalVault() — checks project vault first, then global vault, matching the behavior of tool execution Auth provider delete cleans up tool references: - When deleting an OAuth provider, strips auth config from all tools that referenced it (both in memory and on disk) - Prevents ghost provider showing in UI from tool auth scan Template install creates OAuth providers: - handleTemplateInstall now calls tmpl.ToOAuthProvider() and saves the provider config to factorly.yaml alongside the tools - Fixes "references unknown oauth provider" error after installing OAuth-based templates like google-calendar REST provider skips empty query params: - Empty string values no longer sent as ?timeMin=&timeMax= which caused 400 Bad Request from APIs like Google Calendar - Empty optional param means "not provided", not "empty string" Google Calendar template fixes: - singleEvents defaults to "true" (required when orderBy=startTime) - orderBy defaults to "startTime" - Descriptions clarify the singleEvents/orderBy dependency --- src/cmd/factorly/auth_cmd.go | 6 +++--- src/internal/provider/rest.go | 3 +++ src/internal/templates/yaml/google-calendar.yaml | 6 ++++-- src/internal/ui/handlers_auth.go | 12 +++++++++++- src/internal/ui/handlers_templates.go | 14 ++++++++++++++ 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/cmd/factorly/auth_cmd.go b/src/cmd/factorly/auth_cmd.go index 475a25c..f6c085b 100644 --- a/src/cmd/factorly/auth_cmd.go +++ b/src/cmd/factorly/auth_cmd.go @@ -42,7 +42,7 @@ var authLoginCmd = &cobra.Command{ } // Open vault to resolve refs in client_id/secret and to store tokens - backend, err := getCachedLocalVault() + backend, err := getCachedVault() if err != nil { return err } @@ -95,7 +95,7 @@ var authStatusCmd = &cobra.Command{ return err } - backend, err := getCachedLocalVault() + backend, err := getCachedVault() if err != nil { return err } @@ -158,7 +158,7 @@ var authLogoutCmd = &cobra.Command{ return err } - backend, err := getCachedLocalVault() + backend, err := getCachedVault() if err != nil { return err } diff --git a/src/internal/provider/rest.go b/src/internal/provider/rest.go index ec752bd..f18df30 100644 --- a/src/internal/provider/rest.go +++ b/src/internal/provider/rest.go @@ -256,6 +256,9 @@ func (p *RESTProvider) Execute(toolName string, params map[string]string) (*Resu q := parsedURL.Query() for name, value := range queryParams { + if value == "" { + continue // skip empty optional query params + } q.Set(name, value) } parsedURL.RawQuery = q.Encode() diff --git a/src/internal/templates/yaml/google-calendar.yaml b/src/internal/templates/yaml/google-calendar.yaml index b433360..e226f6f 100644 --- a/src/internal/templates/yaml/google-calendar.yaml +++ b/src/internal/templates/yaml/google-calendar.yaml @@ -24,10 +24,12 @@ google-calendar.list_events: description: Maximum number of events to return in: query - name: singleEvents - description: Whether to expand recurring events (true/false) + description: Whether to expand recurring events into instances. Must be true when using orderBy=startTime. + default: "true" in: query - name: orderBy - description: Sort order (startTime or updated) + description: Sort order (startTime or updated). Requires singleEvents=true for startTime. + default: startTime in: query google-calendar.create_event: diff --git a/src/internal/ui/handlers_auth.go b/src/internal/ui/handlers_auth.go index b69cae0..9a9e97e 100644 --- a/src/internal/ui/handlers_auth.go +++ b/src/internal/ui/handlers_auth.go @@ -119,7 +119,7 @@ func (s *Server) handleAuthDelete(w http.ResponseWriter, r *http.Request) { return } - // Also delete token from vault + // Delete token from vault tokenKey := name + "_oauth" if s.vault != nil { _ = s.vault.Delete(tokenKey) @@ -127,6 +127,16 @@ func (s *Server) handleAuthDelete(w http.ResponseWriter, r *http.Request) { delete(s.cfg.OAuthProviders, name) + // Remove auth references from tools that used this provider + for toolName, tc := range s.cfg.Tools { + if tc.Auth != nil && tc.Auth.Type == "oauth" && tc.Auth.Provider == name { + tc.Auth = nil + s.cfg.Tools[toolName] = tc + _ = SaveTool(s.cfgPath, s.toolsDir, toolName, tc) + s.registerTool(toolName, tc) + } + } + w.Header().Set("HX-Redirect", "/auth") w.WriteHeader(http.StatusOK) } diff --git a/src/internal/ui/handlers_templates.go b/src/internal/ui/handlers_templates.go index 3787200..92d8f58 100644 --- a/src/internal/ui/handlers_templates.go +++ b/src/internal/ui/handlers_templates.go @@ -107,5 +107,19 @@ func (s *Server) handleTemplateInstall(w http.ResponseWriter, r *http.Request) { } } + // Install OAuth provider if the template requires one + if oauthProviders := tmpl.ToOAuthProvider(); oauthProviders != nil { + for provName, provCfg := range oauthProviders { + if err := SaveOAuthProvider(s.cfgPath, provName, provCfg); err != nil { + http.Error(w, fmt.Sprintf("saving oauth provider: %v", err), http.StatusInternalServerError) + return + } + if s.cfg.OAuthProviders == nil { + s.cfg.OAuthProviders = make(map[string]config.OAuthProviderConfig) + } + s.cfg.OAuthProviders[provName] = provCfg + } + } + http.Redirect(w, r, "/tools", http.StatusFound) } From 1a68db1da9e3953ac8f1d983304a368c578c7874 Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Fri, 8 May 2026 00:37:47 -0400 Subject: [PATCH 35/65] Refactor UI: split registerProvider, extract JS, consolidate helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architectural cleanup of the web UI codebase to reduce file sizes, improve discoverability, and eliminate redundancies. No behavior changes. Pure refactor with all tests passing. Split registerProvider (server.go): - 116-line switch statement with CC~14 → 15-line dispatcher calling registerCLIProvider, registerRESTProvider, registerWorkflowProvider - Each helper is 25-40 lines and independently readable Extract resolve* functions (new: resolve.go): - Move 5 vault ref resolution functions from handlers_auth.go to dedicated file: resolveRef, resolveRefTracked, resolveRefsTracked, resolveRefMapTracked, resolveRefT - Used by both server.go (provider registration) and handlers_auth.go (auth refresh) — better discoverability in own file Extract inline JS to static files: - static/activity.js: activity drawer SSE logic (95 lines from layout) - static/confirm.js: confirm modal SSE logic (45 lines from layout) - static/workflow.js: step manipulation functions (170 lines from workflow_edit.html) — addStep, moveStep, swapStepValues, reindexSteps, updateStepSummary, removeStep, addParam - layout.html: 288 → ~140 lines - workflow_edit.html: 369 → ~195 lines - JS now independently cacheable by the browser Extract helpers (new: helpers.go): - dedup() from server.go - splitArgs(), splitComma(), parseFilterForm() from handlers_tools.go - handlers_tools.go: 865 → ~700 lines Consolidate sidebar builders (handlers_tools.go): - getSidebarTools() + getSidebarWorkflows() → both delegate to getSidebarItems(onlyType, excludeType) generic helper - Removes 15 lines of identical filter/sort logic --- src/internal/ui/handlers_auth.go | 63 ------ src/internal/ui/handlers_tools.go | 142 ++---------- src/internal/ui/helpers.go | 126 +++++++++++ src/internal/ui/resolve.go | 67 ++++++ src/internal/ui/server.go | 223 ++++++++++--------- src/internal/ui/static/activity.js | 99 ++++++++ src/internal/ui/static/confirm.js | 42 ++++ src/internal/ui/static/workflow.js | 162 ++++++++++++++ src/internal/ui/templates/layout.html | 140 +----------- src/internal/ui/templates/workflow_edit.html | 176 +-------------- 10 files changed, 628 insertions(+), 612 deletions(-) create mode 100644 src/internal/ui/helpers.go create mode 100644 src/internal/ui/resolve.go create mode 100644 src/internal/ui/static/activity.js create mode 100644 src/internal/ui/static/confirm.js create mode 100644 src/internal/ui/static/workflow.js diff --git a/src/internal/ui/handlers_auth.go b/src/internal/ui/handlers_auth.go index 9a9e97e..3682d18 100644 --- a/src/internal/ui/handlers_auth.go +++ b/src/internal/ui/handlers_auth.go @@ -244,69 +244,6 @@ func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) { s.renderAuthList(w, providers) } -// resolveRef resolves a single {{vault:KEY}} reference using the vault backend. -func (s *Server) resolveRef(val string) string { - v, _ := s.resolveRefTracked(val) - return v -} - -// resolveRefTracked resolves a {{vault:KEY}} reference and returns the -// vault key name if one was accessed (empty string otherwise). -func (s *Server) resolveRefTracked(val string) (string, string) { - if s.vault == nil || val == "" { - return val, "" - } - if len(val) > 10 && val[:8] == "{{vault:" && val[len(val)-2:] == "}}" { - key := val[8 : len(val)-2] - if resolved, err := s.vault.Get(key); err == nil { - return resolved, key - } - return val, key // still track the key even if resolution failed - } - return val, "" -} - -// resolveRefsTracked resolves vault references in a string slice and collects accessed keys. -func (s *Server) resolveRefsTracked(vals []string, keys *[]string) []string { - if s.vault == nil || len(vals) == 0 { - return vals - } - out := make([]string, len(vals)) - for i, v := range vals { - resolved, key := s.resolveRefTracked(v) - out[i] = resolved - if key != "" { - *keys = append(*keys, key) - } - } - return out -} - -// resolveRefMapTracked resolves vault references in a map and collects accessed keys. -func (s *Server) resolveRefMapTracked(m map[string]string, keys *[]string) map[string]string { - if s.vault == nil || len(m) == 0 { - return m - } - out := make(map[string]string, len(m)) - for k, v := range m { - resolved, vaultKey := s.resolveRefTracked(v) - out[k] = resolved - if vaultKey != "" { - *keys = append(*keys, vaultKey) - } - } - return out -} - -// resolveRefT resolves a ref and appends the vault key to the tracker. -func (s *Server) resolveRefT(val string, keys *[]string) string { - resolved, key := s.resolveRefTracked(val) - if key != "" { - *keys = append(*keys, key) - } - return resolved -} - func (s *Server) buildAuthProviderViews() []authProviderView { var views []authProviderView diff --git a/src/internal/ui/handlers_tools.go b/src/internal/ui/handlers_tools.go index b0a9759..d1b816e 100644 --- a/src/internal/ui/handlers_tools.go +++ b/src/internal/ui/handlers_tools.go @@ -16,7 +16,6 @@ import ( "time" "github.com/factorly-dev/factorly/internal/config" - "github.com/factorly-dev/factorly/internal/output" ) type toolListItem struct { @@ -699,111 +698,6 @@ func (s *Server) handleToolDelete(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -// splitArgs splits a space-separated string respecting quoted segments. -func splitArgs(s string) []string { - var args []string - var current []byte - inQuote := false - quoteChar := byte(0) - - for i := 0; i < len(s); i++ { - c := s[i] - if inQuote { - if c == quoteChar { - inQuote = false - } else { - current = append(current, c) - } - } else if c == '"' || c == '\'' { - inQuote = true - quoteChar = c - } else if c == ' ' { - if len(current) > 0 { - args = append(args, string(current)) - current = current[:0] - } - } else { - current = append(current, c) - } - } - if len(current) > 0 { - args = append(args, string(current)) - } - return args -} - -func splitComma(s string) []string { - if s == "" { - return nil - } - var result []string - for _, part := range strings.Split(s, ",") { - part = strings.TrimSpace(part) - if part != "" { - result = append(result, part) - } - } - return result -} - -func parseFilterForm(r *http.Request) *output.FilterConfig { - jsonPath := strings.TrimSpace(r.FormValue("filter_json_path")) - keepLines := splitComma(r.FormValue("filter_keep_lines")) - stripLines := splitComma(r.FormValue("filter_strip_lines")) - headLines := 0 - tailLines := 0 - maxLines := 0 - if v := r.FormValue("filter_head_lines"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - headLines = n - } - } - if v := r.FormValue("filter_tail_lines"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - tailLines = n - } - } - if v := r.FormValue("filter_max_lines"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - maxLines = n - } - } - - var replaces []output.ReplaceConfig - if raw := strings.TrimSpace(r.FormValue("filter_replace")); raw != "" { - for _, line := range strings.Split(raw, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - // Split on → (arrow) separator - parts := strings.SplitN(line, "→", 2) - if len(parts) == 2 { - replaces = append(replaces, output.ReplaceConfig{ - Pattern: strings.TrimSpace(parts[0]), - Replacement: strings.TrimSpace(parts[1]), - }) - } - } - } - - // Only create filter if at least one field is set - if jsonPath == "" && len(keepLines) == 0 && len(stripLines) == 0 && - headLines == 0 && tailLines == 0 && maxLines == 0 && len(replaces) == 0 { - return nil - } - - return &output.FilterConfig{ - JSONPath: jsonPath, - HeadLines: headLines, - TailLines: tailLines, - MaxLines: maxLines, - KeepLines: keepLines, - StripLines: stripLines, - Replace: replaces, - } -} - func (s *Server) render(w http.ResponseWriter, name string, data any) { t, ok := s.tmpls["templates/"+name] if !ok { @@ -832,34 +726,32 @@ func (s *Server) render(w http.ResponseWriter, name string, data any) { } } -func (s *Server) getSidebarTools() []toolListItem { - var tools []toolListItem +// getSidebarItems returns sidebar items filtered by type. +// If onlyType is set, only tools of that type are returned. +// If excludeType is set, tools of that type are excluded. +func (s *Server) getSidebarItems(onlyType, excludeType string) []toolListItem { + var items []toolListItem for name, tc := range s.cfg.Tools { - if tc.Type == "workflow" { + if onlyType != "" && tc.Type != onlyType { continue } - tools = append(tools, toolListItem{ + if excludeType != "" && tc.Type == excludeType { + continue + } + items = append(items, toolListItem{ Name: name, Type: tc.Type, Hidden: tc.Hidden, }) } - sort.Slice(tools, func(i, j int) bool { return tools[i].Name < tools[j].Name }) - return tools + sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) + return items +} + +func (s *Server) getSidebarTools() []toolListItem { + return s.getSidebarItems("", "workflow") } func (s *Server) getSidebarWorkflows() []toolListItem { - var workflows []toolListItem - for name, tc := range s.cfg.Tools { - if tc.Type != "workflow" { - continue - } - workflows = append(workflows, toolListItem{ - Name: name, - Type: tc.Type, - Hidden: tc.Hidden, - }) - } - sort.Slice(workflows, func(i, j int) bool { return workflows[i].Name < workflows[j].Name }) - return workflows + return s.getSidebarItems("workflow", "") } diff --git a/src/internal/ui/helpers.go b/src/internal/ui/helpers.go new file mode 100644 index 0000000..91b1bea --- /dev/null +++ b/src/internal/ui/helpers.go @@ -0,0 +1,126 @@ +// Copyright 2026 Jordan Sherer +// SPDX-License-Identifier: gpl + +package ui + +import ( + "net/http" + "strconv" + "strings" + + "github.com/factorly-dev/factorly/internal/output" +) + +func dedup(s []string) []string { + seen := make(map[string]bool, len(s)) + out := make([]string, 0, len(s)) + for _, v := range s { + if !seen[v] { + seen[v] = true + out = append(out, v) + } + } + return out +} + +func splitArgs(s string) []string { + var args []string + var current []byte + inQuote := false + quoteChar := byte(0) + + for i := 0; i < len(s); i++ { + c := s[i] + if inQuote { + if c == quoteChar { + inQuote = false + } else { + current = append(current, c) + } + } else if c == '"' || c == '\'' { + inQuote = true + quoteChar = c + } else if c == ' ' { + if len(current) > 0 { + args = append(args, string(current)) + current = current[:0] + } + } else { + current = append(current, c) + } + } + if len(current) > 0 { + args = append(args, string(current)) + } + return args +} + +func splitComma(s string) []string { + if s == "" { + return nil + } + var result []string + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + return result +} + +func parseFilterForm(r *http.Request) *output.FilterConfig { + jsonPath := strings.TrimSpace(r.FormValue("filter_json_path")) + keepLines := splitComma(r.FormValue("filter_keep_lines")) + stripLines := splitComma(r.FormValue("filter_strip_lines")) + headLines := 0 + tailLines := 0 + maxLines := 0 + if v := r.FormValue("filter_head_lines"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + headLines = n + } + } + if v := r.FormValue("filter_tail_lines"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + tailLines = n + } + } + if v := r.FormValue("filter_max_lines"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + maxLines = n + } + } + + var replaces []output.ReplaceConfig + if raw := strings.TrimSpace(r.FormValue("filter_replace")); raw != "" { + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, "→", 2) + if len(parts) == 2 { + replaces = append(replaces, output.ReplaceConfig{ + Pattern: strings.TrimSpace(parts[0]), + Replacement: strings.TrimSpace(parts[1]), + }) + } + } + } + + if jsonPath == "" && len(keepLines) == 0 && len(stripLines) == 0 && + headLines == 0 && tailLines == 0 && maxLines == 0 && len(replaces) == 0 { + return nil + } + + return &output.FilterConfig{ + JSONPath: jsonPath, + HeadLines: headLines, + TailLines: tailLines, + MaxLines: maxLines, + KeepLines: keepLines, + StripLines: stripLines, + Replace: replaces, + } +} diff --git a/src/internal/ui/resolve.go b/src/internal/ui/resolve.go new file mode 100644 index 0000000..494949e --- /dev/null +++ b/src/internal/ui/resolve.go @@ -0,0 +1,67 @@ +// Copyright 2026 Jordan Sherer +// SPDX-License-Identifier: gpl + +package ui + +// resolveRef resolves a single {{vault:KEY}} reference using the vault backend. +func (s *Server) resolveRef(val string) string { + v, _ := s.resolveRefTracked(val) + return v +} + +// resolveRefTracked resolves a {{vault:KEY}} reference and returns the +// vault key name if one was accessed (empty string otherwise). +func (s *Server) resolveRefTracked(val string) (string, string) { + if s.vault == nil || val == "" { + return val, "" + } + if len(val) > 10 && val[:8] == "{{vault:" && val[len(val)-2:] == "}}" { + key := val[8 : len(val)-2] + if resolved, err := s.vault.Get(key); err == nil { + return resolved, key + } + return val, key // still track the key even if resolution failed + } + return val, "" +} + +// resolveRefsTracked resolves vault references in a string slice and collects accessed keys. +func (s *Server) resolveRefsTracked(vals []string, keys *[]string) []string { + if s.vault == nil || len(vals) == 0 { + return vals + } + out := make([]string, len(vals)) + for i, v := range vals { + resolved, key := s.resolveRefTracked(v) + out[i] = resolved + if key != "" { + *keys = append(*keys, key) + } + } + return out +} + +// resolveRefMapTracked resolves vault references in a map and collects accessed keys. +func (s *Server) resolveRefMapTracked(m map[string]string, keys *[]string) map[string]string { + if s.vault == nil || len(m) == 0 { + return m + } + out := make(map[string]string, len(m)) + for k, v := range m { + resolved, vaultKey := s.resolveRefTracked(v) + out[k] = resolved + if vaultKey != "" { + *keys = append(*keys, vaultKey) + } + } + return out +} + +// resolveRefT resolves a ref and appends the vault key to the tracker. +func (s *Server) resolveRefT(val string, keys *[]string) string { + resolved, key := s.resolveRefTracked(val) + if key != "" { + *keys = append(*keys, key) + } + return resolved +} diff --git a/src/internal/ui/server.go b/src/internal/ui/server.go index 5fabe61..680e812 100644 --- a/src/internal/ui/server.go +++ b/src/internal/ui/server.go @@ -260,118 +260,131 @@ func (s *Server) registerProvider(name string, tc config.ToolConfig) []string { if s.proxy == nil { return nil } - var vaultKeys []string - switch tc.Type { case "cli": - prov := s.proxy.Provider("cli") - if prov == nil { - cp := provider.NewCLI(map[string]provider.CLIToolDef{}) - _ = cp.Setup() - s.proxy.RegisterProvider("cli", cp) - prov = cp - } - if cp, ok := prov.(*provider.CLIProvider); ok { - def := provider.CLIToolDef{ - Command: s.resolveRefT(tc.Command, &vaultKeys), - Args: s.resolveRefsTracked(tc.Args, &vaultKeys), - Stdin: s.resolveRefT(tc.Stdin, &vaultKeys), - Interactive: tc.Interactive, - Env: s.resolveRefMapTracked(tc.Env, &vaultKeys), - EnvStrict: tc.EnvIsolation == "strict", - } - if tc.Timeout != "" { - if d, err := time.ParseDuration(tc.Timeout); err == nil { - def.Timeout = d - } - } - cp.AddTool(name, def) + s.registerCLIProvider(name, tc, &vaultKeys) + case "rest": + s.registerRESTProvider(name, tc, &vaultKeys) + case "workflow": + s.registerWorkflowProvider(name, tc) + } + return vaultKeys +} + +func (s *Server) registerCLIProvider(name string, tc config.ToolConfig, vaultKeys *[]string) { + prov := s.proxy.Provider("cli") + if prov == nil { + cp := provider.NewCLI(map[string]provider.CLIToolDef{}) + _ = cp.Setup() + s.proxy.RegisterProvider("cli", cp) + prov = cp + } + cp, ok := prov.(*provider.CLIProvider) + if !ok { + return + } + def := provider.CLIToolDef{ + Command: s.resolveRefT(tc.Command, vaultKeys), + Args: s.resolveRefsTracked(tc.Args, vaultKeys), + Stdin: s.resolveRefT(tc.Stdin, vaultKeys), + Interactive: tc.Interactive, + Env: s.resolveRefMapTracked(tc.Env, vaultKeys), + EnvStrict: tc.EnvIsolation == "strict", + } + if tc.Timeout != "" { + if d, err := time.ParseDuration(tc.Timeout); err == nil { + def.Timeout = d } + } + cp.AddTool(name, def) +} - case "rest": - prov := s.proxy.Provider("rest") - if prov == nil { - rp := provider.NewREST(map[string]provider.RESTToolDef{}, nil) - _ = rp.Setup() - s.proxy.RegisterProvider("rest", rp) - prov = rp +func (s *Server) registerRESTProvider(name string, tc config.ToolConfig, vaultKeys *[]string) { + prov := s.proxy.Provider("rest") + if prov == nil { + rp := provider.NewREST(map[string]provider.RESTToolDef{}, nil) + _ = rp.Setup() + s.proxy.RegisterProvider("rest", rp) + prov = rp + } + rp, ok := prov.(*provider.RESTProvider) + if !ok { + return + } + def := provider.RESTToolDef{ + Method: tc.Method, + BaseURL: s.resolveRefT(tc.BaseURL, vaultKeys), + Path: tc.Path, + Body: tc.Body, + Headers: s.resolveRefMapTracked(tc.Headers, vaultKeys), + } + if tc.Auth != nil { + def.Auth = &provider.AuthDef{ + Type: tc.Auth.Type, + Token: s.resolveRefT(tc.Auth.Token, vaultKeys), + Header: tc.Auth.Header, + Value: s.resolveRefT(tc.Auth.Value, vaultKeys), } - if rp, ok := prov.(*provider.RESTProvider); ok { - def := provider.RESTToolDef{ - Method: tc.Method, - BaseURL: s.resolveRefT(tc.BaseURL, &vaultKeys), - Path: tc.Path, - Body: tc.Body, - Headers: s.resolveRefMapTracked(tc.Headers, &vaultKeys), - } - if tc.Auth != nil { - def.Auth = &provider.AuthDef{ - Type: tc.Auth.Type, - Token: s.resolveRefT(tc.Auth.Token, &vaultKeys), - Header: tc.Auth.Header, - Value: s.resolveRefT(tc.Auth.Value, &vaultKeys), - } - if tc.Auth.Type == "oauth" && s.cfg != nil { - oauthCfg := s.cfg.ResolveOAuthProvider(tc.Auth) - if oauthCfg != nil { - def.Auth.OAuthProvider = &oauth.ProviderConfig{ - ClientID: s.resolveRefT(oauthCfg.ClientID, &vaultKeys), - ClientSecret: s.resolveRefT(oauthCfg.ClientSecret, &vaultKeys), - AuthURL: oauthCfg.AuthURL, - TokenURL: oauthCfg.TokenURL, - Scopes: oauthCfg.Scopes, - } - def.Auth.TokenKey = config.OAuthTokenKey(tc.Auth) - } - } - } - for _, p := range tc.Parameters { - def.Params = append(def.Params, provider.RESTParamDef{ - Name: p.Name, - In: p.In, - Required: p.Required, - Type: p.Type, - }) - } - if tc.Timeout != "" { - if d, err := time.ParseDuration(tc.Timeout); err == nil { - def.Timeout = d + if tc.Auth.Type == "oauth" && s.cfg != nil { + oauthCfg := s.cfg.ResolveOAuthProvider(tc.Auth) + if oauthCfg != nil { + def.Auth.OAuthProvider = &oauth.ProviderConfig{ + ClientID: s.resolveRefT(oauthCfg.ClientID, vaultKeys), + ClientSecret: s.resolveRefT(oauthCfg.ClientSecret, vaultKeys), + AuthURL: oauthCfg.AuthURL, + TokenURL: oauthCfg.TokenURL, + Scopes: oauthCfg.Scopes, } + def.Auth.TokenKey = config.OAuthTokenKey(tc.Auth) } - rp.AddTool(name, def) } + } + for _, p := range tc.Parameters { + def.Params = append(def.Params, provider.RESTParamDef{ + Name: p.Name, + In: p.In, + Required: p.Required, + Type: p.Type, + }) + } + if tc.Timeout != "" { + if d, err := time.ParseDuration(tc.Timeout); err == nil { + def.Timeout = d + } + } + rp.AddTool(name, def) +} - case "workflow": - prov := s.proxy.Provider("workflow") - if prov == nil { - return nil // workflow provider needs proxy ref, can't create standalone +func (s *Server) registerWorkflowProvider(name string, tc config.ToolConfig) { + prov := s.proxy.Provider("workflow") + if prov == nil { + return + } + wp, ok := prov.(*provider.WorkflowProvider) + if !ok { + return + } + steps := make([]provider.WorkflowStep, len(tc.Steps)) + for i, st := range tc.Steps { + ws := provider.WorkflowStep{ + Tool: st.Tool, + Params: st.Params, + Store: st.Store, + If: st.If, + Require: st.Require, } - if wp, ok := prov.(*provider.WorkflowProvider); ok { - steps := make([]provider.WorkflowStep, len(tc.Steps)) - for i, st := range tc.Steps { - ws := provider.WorkflowStep{ - Tool: st.Tool, - Params: st.Params, - Store: st.Store, - If: st.If, - Require: st.Require, - } - for _, sc := range st.Switch { - ws.Switch = append(ws.Switch, provider.WorkflowSwitchCase{ - Condition: sc.Condition, - Tool: sc.Tool, - Params: sc.Params, - Store: sc.Store, - }) - } - steps[i] = ws - } - wp.RegisterWorkflow(name, steps) + for _, sc := range st.Switch { + ws.Switch = append(ws.Switch, provider.WorkflowSwitchCase{ + Condition: sc.Condition, + Tool: sc.Tool, + Params: sc.Params, + Store: sc.Store, + }) } + steps[i] = ws } - - return vaultKeys + wp.RegisterWorkflow(name, steps) } // unregisterTool removes a tool from the live registry and provider. @@ -432,18 +445,6 @@ func (s *Server) updateShadowRule(name string, tc config.ToolConfig) { policy.SetRule(name, rule) } -func dedup(s []string) []string { - seen := make(map[string]bool, len(s)) - out := make([]string, 0, len(s)) - for _, v := range s { - if !seen[v] { - seen[v] = true - out = append(out, v) - } - } - return out -} - func templateFuncs() template.FuncMap { return template.FuncMap{ "inc": func(i int) int { return i + 1 }, diff --git a/src/internal/ui/static/activity.js b/src/internal/ui/static/activity.js new file mode 100644 index 0000000..8500e42 --- /dev/null +++ b/src/internal/ui/static/activity.js @@ -0,0 +1,99 @@ +// Copyright 2026 Jordan Sherer +// SPDX-License-Identifier: gpl + +// Activity drawer — use var to avoid redeclaration on hx-boost navigation +if (typeof activityOpen === 'undefined') { + var activityOpen = false; + var activityCount = 0; +} + +function toggleActivity() { + activityOpen = !activityOpen; + document.getElementById('activity-drawer').classList.toggle('open', activityOpen); +} + +function clearActivity() { + document.getElementById('activity-feed').innerHTML = '
Waiting for agent activity...
'; + activityCount = 0; + document.getElementById('drawer-count').textContent = '0 calls'; + document.getElementById('activity-badge').textContent = 'Activity'; +} + +// SSE connection (singleton — only create once across hx-boost navigations) +if (!window._activitySSE) { + window._activitySSE = new EventSource('/activity/stream'); + + window._activitySSE.onmessage = function(event) { + const data = JSON.parse(event.data); + const feed = document.getElementById('activity-feed'); + + // Remove empty state + const empty = document.getElementById('activity-empty'); + if (empty) empty.remove(); + + let icon = '✓'; + let iconClass = 'bg-green-100 text-green-600'; + if (data.status === 'blocked') { + icon = '✗'; + iconClass = 'bg-red-100 text-red-600'; + } else if (data.status === 'error') { + icon = '!'; + iconClass = 'bg-amber-100 text-amber-600'; + } + + let shadow = ''; + if (data.shadow && data.shadow !== 'allowed') { + shadow = `${escapeHtml(data.shadow)}`; + } + + let paramsHtml = ''; + if (data.params && Object.keys(data.params).length > 0) { + paramsHtml = '
Params
'; + for (const [k, v] of Object.entries(data.params)) { + paramsHtml += `
${escapeHtml(k)}
${escapeHtml(v)}
`; + } + paramsHtml += '
'; + } + + let detailHtml = ''; + if (data.error) { + detailHtml += `
${escapeHtml(data.error)}
`; + } + if (data.output) { + detailHtml += `
${escapeHtml(data.output)}
`; + } + + const row = document.createElement('details'); + row.className = 'border-b border-gray-100'; + row.innerHTML = ` + +
+ ${icon} + ${escapeHtml(data.tool)} + ${shadow} +
+
+ ${data.duration_ms}ms + ${data.timestamp} +
+
+
+ ${paramsHtml} + ${detailHtml} + ${!paramsHtml && !detailHtml ? 'No details' : ''} +
+ `; + + feed.insertBefore(row, feed.firstChild); + + activityCount++; + document.getElementById('drawer-count').textContent = activityCount + (activityCount === 1 ? ' call' : ' calls'); + document.getElementById('activity-badge').textContent = 'Activity (' + activityCount + ')'; + + // Flash the dot + const dot = document.getElementById('activity-dot'); + dot.classList.remove('bg-green-500'); + dot.classList.add('bg-indigo-500'); + setTimeout(() => { dot.classList.remove('bg-indigo-500'); dot.classList.add('bg-green-500'); }, 300); + }; +} diff --git a/src/internal/ui/static/confirm.js b/src/internal/ui/static/confirm.js new file mode 100644 index 0000000..0f9b4bc --- /dev/null +++ b/src/internal/ui/static/confirm.js @@ -0,0 +1,42 @@ +// Copyright 2026 Jordan Sherer +// SPDX-License-Identifier: gpl + +// Confirm modal — SSE stream for shadow confirm prompts +if (!window._confirmSSE) { + window._confirmSSE = new EventSource('/confirm/stream'); + window._confirmSSE.onmessage = function(event) { + const pending = JSON.parse(event.data); + const modal = document.getElementById('confirm-modal'); + if (!pending || pending.length === 0) { + modal.classList.add('hidden'); + return; + } + const req = pending[0]; + let paramsHtml = ''; + if (req.params && Object.keys(req.params).length > 0) { + paramsHtml = '
'; + for (const [k, v] of Object.entries(req.params)) { + paramsHtml += `
${escapeHtml(k)}${escapeHtml(v)}
`; + } + paramsHtml += '
'; + } + document.getElementById('confirm-content').innerHTML = ` +
+ ! + ${escapeHtml(req.tool)} +
+

This tool requires confirmation before executing.

+ ${paramsHtml} +
+ + +
+ `; + modal.classList.remove('hidden'); + }; +} + +function respondConfirm(id, action) { + fetch(`/confirm/${id}/${action}`, { method: 'POST' }); + document.getElementById('confirm-modal').classList.add('hidden'); +} diff --git a/src/internal/ui/static/workflow.js b/src/internal/ui/static/workflow.js new file mode 100644 index 0000000..53f44bf --- /dev/null +++ b/src/internal/ui/static/workflow.js @@ -0,0 +1,162 @@ +// Copyright 2026 Jordan Sherer +// SPDX-License-Identifier: gpl + +function addStep() { + const list = document.getElementById('steps-list'); + const rows = list.querySelectorAll('.step-row'); + const idx = rows.length; + + const empty = document.getElementById('empty-state'); + if (empty) empty.remove(); + + let options = ''; + const firstSelect = list.querySelector('.step-tool'); + if (firstSelect) options = firstSelect.innerHTML; + + const details = document.createElement('details'); + details.className = 'step-row border-b border-gray-100 last:border-b-0'; + details.dataset.index = idx; + details.open = true; + details.innerHTML = ` + + ${idx + 1} + new step + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ `; + list.appendChild(details); +} + +function addParam(btn) { + const stepRow = btn.closest('.step-row'); + const stepIdx = stepRow.dataset.index; + const container = btn.previousElementSibling; + const row = document.createElement('div'); + row.className = 'param-row flex gap-1'; + row.innerHTML = ` + + + + `; + container.appendChild(row); +} + +function reindexSteps() { + const list = document.getElementById('steps-list'); + const rows = list.querySelectorAll('.step-row'); + rows.forEach((r, i) => { + r.dataset.index = i; + const num = r.querySelector('.step-num'); + if (num) num.textContent = i + 1; + r.querySelectorAll('[name^="step_tool_"]').forEach(el => el.name = 'step_tool_' + i); + r.querySelectorAll('[name^="step_store_"]').forEach(el => el.name = 'step_store_' + i); + r.querySelectorAll('[name^="step_if_"]').forEach(el => el.name = 'step_if_' + i); + r.querySelectorAll('[name^="step_require_"]').forEach(el => el.name = 'step_require_' + i); + r.querySelectorAll('[name^="step_param_key_"]').forEach(el => el.name = 'step_param_key_' + i + '[]'); + r.querySelectorAll('[name^="step_param_val_"]').forEach(el => el.name = 'step_param_val_' + i + '[]'); + const params = r.querySelector('.step-params'); + if (params) params.dataset.index = i; + }); +} + +function moveStep(btn, direction) { + const row = btn.closest('.step-row'); + const list = document.getElementById('steps-list'); + const rows = Array.from(list.querySelectorAll('.step-row')); + const idx = rows.indexOf(row); + const targetIdx = idx + direction; + if (targetIdx < 0 || targetIdx >= rows.length) return; + + const other = rows[targetIdx]; + swapStepValues(row, other); + updateStepSummary(row); + updateStepSummary(other); +} + +function swapStepValues(rowA, rowB) { + const selA = rowA.querySelector('.step-tool'); + const selB = rowB.querySelector('.step-tool'); + if (selA && selB) { + const tmp = selA.value; + selA.value = selB.value; + selB.value = tmp; + } + + const inputsA = rowA.querySelectorAll('input[type="text"]'); + const inputsB = rowB.querySelectorAll('input[type="text"]'); + const coreInputsA = Array.from(inputsA).filter(el => !el.name.includes('param')); + const coreInputsB = Array.from(inputsB).filter(el => !el.name.includes('param')); + + for (let i = 0; i < Math.min(coreInputsA.length, coreInputsB.length); i++) { + const tmp = coreInputsA[i].value; + coreInputsA[i].value = coreInputsB[i].value; + coreInputsB[i].value = tmp; + } + + const paramsA = rowA.querySelector('.step-params'); + const paramsB = rowB.querySelector('.step-params'); + if (paramsA && paramsB) { + const tmpHTML = paramsA.innerHTML; + paramsA.innerHTML = paramsB.innerHTML; + paramsB.innerHTML = tmpHTML; + } + + reindexSteps(); +} + +function updateStepSummary(row) { + const sel = row.querySelector('.step-tool'); + const summaryTool = row.querySelector('summary .font-mono'); + if (sel && summaryTool) { + summaryTool.textContent = sel.value || 'new step'; + } + const storeInput = row.querySelector('input[name^="step_store_"]'); + const storeSpan = row.querySelector('summary .text-indigo-600'); + const arrowSpan = storeSpan ? storeSpan.previousElementSibling : null; + if (storeInput && storeSpan) { + if (storeInput.value) { + storeSpan.textContent = storeInput.value; + storeSpan.classList.remove('hidden'); + if (arrowSpan) arrowSpan.classList.remove('hidden'); + } else { + storeSpan.classList.add('hidden'); + if (arrowSpan) arrowSpan.classList.add('hidden'); + } + } +} + +function removeStep(btn) { + const row = btn.closest('.step-row'); + row.remove(); + reindexSteps(); + if (document.querySelectorAll('.step-row').length === 0) { + document.getElementById('steps-list').innerHTML = '
No steps. Click "+ Add" to start.
'; + } +} diff --git a/src/internal/ui/templates/layout.html b/src/internal/ui/templates/layout.html index 33a1f7f..97f41a9 100644 --- a/src/internal/ui/templates/layout.html +++ b/src/internal/ui/templates/layout.html @@ -133,151 +133,15 @@ item.style.display = item.textContent.toLowerCase().includes(q) ? '' : 'none'; }); } - - // Activity drawer — use var to avoid redeclaration on hx-boost navigation - if (typeof activityOpen === 'undefined') { - var activityOpen = false; - var activityCount = 0; - } - - function toggleActivity() { - activityOpen = !activityOpen; - document.getElementById('activity-drawer').classList.toggle('open', activityOpen); - } - - function clearActivity() { - document.getElementById('activity-feed').innerHTML = '
Waiting for agent activity...
'; - activityCount = 0; - document.getElementById('drawer-count').textContent = '0 calls'; - document.getElementById('activity-badge').textContent = 'Activity'; - } - - // SSE connection (singleton — only create once across hx-boost navigations) - if (!window._activitySSE) { - window._activitySSE = new EventSource('/activity/stream'); - - window._activitySSE.onmessage = function(event) { - const data = JSON.parse(event.data); - const feed = document.getElementById('activity-feed'); - - // Remove empty state - const empty = document.getElementById('activity-empty'); - if (empty) empty.remove(); - - let icon = '✓'; - let iconClass = 'bg-green-100 text-green-600'; - if (data.status === 'blocked') { - icon = '✗'; - iconClass = 'bg-red-100 text-red-600'; - } else if (data.status === 'error') { - icon = '!'; - iconClass = 'bg-amber-100 text-amber-600'; - } - - let shadow = ''; - if (data.shadow && data.shadow !== 'allowed') { - shadow = `${escapeHtml(data.shadow)}`; - } - - let paramsHtml = ''; - if (data.params && Object.keys(data.params).length > 0) { - paramsHtml = '
Params
'; - for (const [k, v] of Object.entries(data.params)) { - paramsHtml += `
${escapeHtml(k)}
${escapeHtml(v)}
`; - } - paramsHtml += '
'; - } - - let detailHtml = ''; - if (data.error) { - detailHtml += `
${escapeHtml(data.error)}
`; - } - if (data.output) { - detailHtml += `
${escapeHtml(data.output)}
`; - } - - const row = document.createElement('details'); - row.className = 'border-b border-gray-100'; - row.innerHTML = ` - -
- ${icon} - ${escapeHtml(data.tool)} - ${shadow} -
-
- ${data.duration_ms}ms - ${data.timestamp} -
-
-
- ${paramsHtml} - ${detailHtml} - ${!paramsHtml && !detailHtml ? 'No details' : ''} -
- `; - - feed.insertBefore(row, feed.firstChild); - - activityCount++; - document.getElementById('drawer-count').textContent = activityCount + (activityCount === 1 ? ' call' : ' calls'); - document.getElementById('activity-badge').textContent = 'Activity (' + activityCount + ')'; - - // Flash the dot - const dot = document.getElementById('activity-dot'); - dot.classList.remove('bg-green-500'); - dot.classList.add('bg-indigo-500'); - setTimeout(() => { dot.classList.remove('bg-indigo-500'); dot.classList.add('bg-green-500'); }, 300); - }; - } - function escapeHtml(s) { if (!s) return ''; const div = document.createElement('div'); div.textContent = s; return div.innerHTML; } - - // Confirm modal — SSE stream for shadow confirm prompts - if (!window._confirmSSE) { - window._confirmSSE = new EventSource('/confirm/stream'); - window._confirmSSE.onmessage = function(event) { - const pending = JSON.parse(event.data); - const modal = document.getElementById('confirm-modal'); - if (!pending || pending.length === 0) { - modal.classList.add('hidden'); - return; - } - const req = pending[0]; - let paramsHtml = ''; - if (req.params && Object.keys(req.params).length > 0) { - paramsHtml = '
'; - for (const [k, v] of Object.entries(req.params)) { - paramsHtml += `
${escapeHtml(k)}${escapeHtml(v)}
`; - } - paramsHtml += '
'; - } - document.getElementById('confirm-content').innerHTML = ` -
- ! - ${escapeHtml(req.tool)} -
-

This tool requires confirmation before executing.

- ${paramsHtml} -
- - -
- `; - modal.classList.remove('hidden'); - }; - } - - function respondConfirm(id, action) { - fetch(`/confirm/${id}/${action}`, { method: 'POST' }); - document.getElementById('confirm-modal').classList.add('hidden'); - } + +
- + {{end}} From 53b3fb559dcd01e63745bb60c9392befa9448044 Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Fri, 8 May 2026 00:53:46 -0400 Subject: [PATCH 36/65] Move inline HTML from handlers to template partials, delete dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ~325 lines of fmt.Fprintf HTML generation in Go handlers with proper Go template partials. Add renderPartial() method for rendering named partials independently for htmx responses. Delete unused activity.html template (activity is now a drawer in layout). New partials: - partials/try_panel.html: Try It form with params, replaces 54-line handleToolTryPanel inline HTML generation - partials/try_result.html: try_blocked and try_success templates, replaces renderBlockedResponse (15 lines) and renderSuccessResponse (80 lines) with status bar, Response/Raw tabs, and JSON highlighting - partials/tool_form_fields.html: tool_form_cli, tool_form_rest, tool_form_mcp templates, replaces 92-line handleToolFormPartial switch with type-specific form field partials - partials/import_preview.html: import_preview, import_error, import_warning templates, replaces 85-line handleImportPreview inline HTML with select-all checkbox, filter, and tool checklist Handler changes: - renderBlockedResponse/renderSuccessResponse: now Server methods delegating to renderPartial instead of fmt.Fprintf - handleToolTryPanel: 54 lines → 5 lines (renderPartial call) - handleToolFormPartial: 92 lines → 8 lines (partial name dispatch) - handleImportPreview: 85 lines → structured data + renderPartial - Remove fmt/html/template imports from handlers_import.go New renderPartial method (handlers_tools.go): - Executes named partial template for htmx fragment responses - Uses any available page template (all include partials via ParseFS) Dead code removed: - templates/activity.html (123 lines) — activity moved to layout drawer, this standalone page was unused since the drawer refactor - Removed from server.go template parse list --- src/internal/ui/handlers_import.go | 81 ++---- src/internal/ui/handlers_tools.go | 256 ++++-------------- src/internal/ui/server.go | 1 - src/internal/ui/templates/activity.html | 123 --------- .../ui/templates/partials/import_preview.html | 41 +++ .../templates/partials/tool_form_fields.html | 91 +++++++ .../ui/templates/partials/try_panel.html | 40 +++ .../ui/templates/partials/try_result.html | 58 ++++ 8 files changed, 306 insertions(+), 385 deletions(-) delete mode 100644 src/internal/ui/templates/activity.html create mode 100644 src/internal/ui/templates/partials/import_preview.html create mode 100644 src/internal/ui/templates/partials/tool_form_fields.html create mode 100644 src/internal/ui/templates/partials/try_panel.html create mode 100644 src/internal/ui/templates/partials/try_result.html diff --git a/src/internal/ui/handlers_import.go b/src/internal/ui/handlers_import.go index a6be069..273b0f9 100644 --- a/src/internal/ui/handlers_import.go +++ b/src/internal/ui/handlers_import.go @@ -4,8 +4,6 @@ package ui import ( - "fmt" - "html/template" "net/http" "os" "path/filepath" @@ -34,89 +32,68 @@ func (s *Server) handleImportPreview(w http.ResponseWriter, r *http.Request) { prefix := strings.TrimSpace(r.FormValue("prefix")) if specURL == "" { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, `
Please provide a spec URL or path.
`) + s.renderPartial(w, "import_error", map[string]any{"Message": "Please provide a spec URL or path."}) return } tools, err := openapi.Generate(specURL, openapi.GenerateOpts{Prefix: prefix}) if err != nil { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, `
Error: %s
`, template.HTMLEscapeString(err.Error())) + s.renderPartial(w, "import_error", map[string]any{"Message": "Error: " + err.Error()}) return } if len(tools) == 0 { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, `
No endpoints found in the spec.
`) + s.renderPartial(w, "import_warning", map[string]any{"Message": "No endpoints found in the spec."}) return } - // Sort tool names for display + // Build sorted tool list with method colors + type toolPreview struct { + Name string + Method string + MethodColor string + Description string + } var names []string for name := range tools { names = append(names, name) } sort.Strings(names) - // Render preview - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprintf(w, `
-
- -
-
- -
- - - -
`, len(tools), template.HTMLEscapeString(specURL), template.HTMLEscapeString(prefix)) - + var previews []toolPreview for _, name := range names { tc := tools[name] method := tc.Method if method == "" { method = "GET" } - methodColor := "gray" + color := "gray" switch method { case "GET": - methodColor = "green" + color = "green" case "POST": - methodColor = "blue" + color = "blue" case "PUT": - methodColor = "orange" + color = "orange" case "PATCH": - methodColor = "yellow" + color = "yellow" case "DELETE": - methodColor = "red" + color = "red" } - - fmt.Fprintf(w, ``, - template.HTMLEscapeString(name), - methodColor, methodColor, method, - template.HTMLEscapeString(name), - template.HTMLEscapeString(tc.Description)) + previews = append(previews, toolPreview{ + Name: name, + Method: method, + MethodColor: color, + Description: tc.Description, + }) } - fmt.Fprint(w, `
-
- - Uncheck tools you don't want to import. -
- -
`) + s.renderPartial(w, "import_preview", map[string]any{ + "Count": len(tools), + "SpecURL": specURL, + "Prefix": prefix, + "Tools": previews, + }) } func (s *Server) handleImportConfirm(w http.ResponseWriter, r *http.Request) { diff --git a/src/internal/ui/handlers_tools.go b/src/internal/ui/handlers_tools.go index d1b816e..9c8527f 100644 --- a/src/internal/ui/handlers_tools.go +++ b/src/internal/ui/handlers_tools.go @@ -85,53 +85,10 @@ func (s *Server) handleToolTryPanel(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - - fmt.Fprint(w, `
-
-

Try it

`) - fmt.Fprintf(w, `%s -
`, template.HTMLEscapeString(name)) - - fmt.Fprintf(w, `
`, template.HTMLEscapeString(name)) - - if len(tc.Parameters) > 0 { - fmt.Fprint(w, `
`) - for _, p := range tc.Parameters { - fmt.Fprintf(w, `
- `) - if p.Description != "" { - fmt.Fprintf(w, `

%s

`, template.HTMLEscapeString(p.Description)) - } - fmt.Fprintf(w, `
`) - } - fmt.Fprint(w, `
`) - } else { - fmt.Fprint(w, `

No parameters.

`) - } - - fmt.Fprint(w, ` - - running... - -
-
-
`) + s.renderPartial(w, "try_panel", map[string]any{ + "Name": name, + "Params": tc.Parameters, + }) } func (s *Server) handleToolTry(w http.ResponseWriter, r *http.Request) { @@ -158,7 +115,7 @@ func (s *Server) handleToolTry(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if execErr != nil { - renderBlockedResponse(w, name, duration, execErr) + s.renderBlockedResponse(w, name, duration, execErr) return } @@ -173,41 +130,28 @@ func (s *Server) handleToolTry(w http.ResponseWriter, r *http.Request) { } } - renderSuccessResponse(w, name, status, statusIcon, duration, output) + s.renderSuccessResponse(w, name, status, statusIcon, duration, output) } -func renderBlockedResponse(w http.ResponseWriter, tool string, dur time.Duration, err error) { - fmt.Fprintf(w, ` -
-
-
- - blocked - %s -
- %dms -
-
-
%s
-
-
`, tool, dur.Milliseconds(), template.HTMLEscapeString(err.Error())) +func (s *Server) renderBlockedResponse(w http.ResponseWriter, tool string, dur time.Duration, err error) { + s.renderPartial(w, "try_blocked", map[string]any{ + "Tool": tool, + "DurationMs": dur.Milliseconds(), + "Error": err.Error(), + }) } -func renderSuccessResponse(w http.ResponseWriter, tool, status, icon string, dur time.Duration, output string) { +func (s *Server) renderSuccessResponse(w http.ResponseWriter, tool, status, icon string, dur time.Duration, output string) { statusColor := "green" - statusBg := "green" textColor := "green-300" if status == "error" { statusColor = "amber" - statusBg = "amber" textColor = "amber-300" } - // Detect if output is JSON for formatting isJSON := len(output) > 0 && (output[0] == '{' || output[0] == '[') formattedOutput := template.HTMLEscapeString(output) if isJSON { - // Pretty-print JSON before highlighting var prettyBuf bytes.Buffer if json.Indent(&prettyBuf, []byte(output), "", " ") == nil { formattedOutput = formatJSONHTML(prettyBuf.String()) @@ -218,60 +162,17 @@ func renderSuccessResponse(w http.ResponseWriter, tool, status, icon string, dur tabID := fmt.Sprintf("tab-%d", time.Now().UnixNano()) - fmt.Fprintf(w, ` -
- -
-
- %s - %s - %s -
- %dms -
- - -
- - -
- - -
-
-
%s
-
- -
-
- -`, - statusColor, - statusBg, - statusColor, statusColor, icon, - statusColor, status, - statusColor, tool, - dur.Milliseconds(), - tabID, tabID, - tabID, textColor, formattedOutput, - tabID, template.HTMLEscapeString(output), - ) + s.renderPartial(w, "try_success", map[string]any{ + "Tool": tool, + "Status": status, + "StatusColor": statusColor, + "Icon": icon, + "DurationMs": dur.Milliseconds(), + "TabID": tabID, + "TextColor": textColor, + "FormattedOutput": template.HTML(formattedOutput), + "RawOutput": output, + }) } // formatJSONHTML returns syntax-highlighted HTML for JSON content. @@ -352,96 +253,14 @@ func formatJSONHTML(s string) string { func (s *Server) handleToolFormPartial(w http.ResponseWriter, r *http.Request) { toolType := r.URL.Query().Get("type") - w.Header().Set("Content-Type", "text/html; charset=utf-8") - + partialName := "tool_form_cli" switch toolType { case "rest": - fmt.Fprint(w, `
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- `) + partialName = "tool_form_rest" case "mcp": - fmt.Fprint(w, `
-
- - -
-
- - -

Space-separated arguments for the MCP server command.

-
-
- - -

Leave empty for stdio transport (command-based).

-
-
`) - default: // cli - fmt.Fprint(w, `
-
- - -
-
- - -

Space-separated. Use {{param}} for placeholders.

-
-
`) + partialName = "tool_form_mcp" } + s.renderPartial(w, partialName, nil) } func (s *Server) handleToolNew(w http.ResponseWriter, r *http.Request) { @@ -726,6 +545,25 @@ func (s *Server) render(w http.ResponseWriter, name string, data any) { } } +// renderPartial renders a named partial template (from templates/partials/). +// Uses any available page template since partials are parsed with all of them. +func (s *Server) renderPartial(w http.ResponseWriter, partialName string, data any) { + // Use the first available template (all include partials) + var t *template.Template + for _, tmpl := range s.tmpls { + t = tmpl + break + } + if t == nil { + http.Error(w, "no templates available", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := t.ExecuteTemplate(w, partialName, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + // getSidebarItems returns sidebar items filtered by type. // If onlyType is set, only tools of that type are returned. // If excludeType is set, tools of that type are excluded. diff --git a/src/internal/ui/server.go b/src/internal/ui/server.go index 680e812..d411da0 100644 --- a/src/internal/ui/server.go +++ b/src/internal/ui/server.go @@ -68,7 +68,6 @@ func New(opts Options) (*Server, error) { "templates/workflows.html", "templates/workflow_new.html", "templates/workflow_edit.html", - "templates/activity.html", "templates/history.html", "templates/auth.html", "templates/vault.html", diff --git a/src/internal/ui/templates/activity.html b/src/internal/ui/templates/activity.html deleted file mode 100644 index 9fd4933..0000000 --- a/src/internal/ui/templates/activity.html +++ /dev/null @@ -1,123 +0,0 @@ - - -{{define "content"}} -
-
-

Activity

- - - live - -
- -
- -
-
- Live tool calls - 0 calls -
- -
-
-

Waiting for agent activity...

-

Tool calls will appear here in real-time as connected agents make them.

-
-
-
- - -{{end}} diff --git a/src/internal/ui/templates/partials/import_preview.html b/src/internal/ui/templates/partials/import_preview.html new file mode 100644 index 0000000..8053ecb --- /dev/null +++ b/src/internal/ui/templates/partials/import_preview.html @@ -0,0 +1,41 @@ +{{define "import_error"}} +
{{.Message}}
+{{end}} + +{{define "import_warning"}} +
{{.Message}}
+{{end}} + +{{define "import_preview"}} +
+
+ +
+
+ +
+
+ + +
+ {{range .Tools}} + + {{end}} +
+
+ + Uncheck tools you don't want to import. +
+
+
+{{end}} diff --git a/src/internal/ui/templates/partials/tool_form_fields.html b/src/internal/ui/templates/partials/tool_form_fields.html new file mode 100644 index 0000000..62e3b3d --- /dev/null +++ b/src/internal/ui/templates/partials/tool_form_fields.html @@ -0,0 +1,91 @@ +{{define "tool_form_cli"}} +
+
+ + +
+
+ + +

Space-separated. Use {{"{{param}}"}} for placeholders.

+
+
+{{end}} + +{{define "tool_form_rest"}} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +{{end}} + +{{define "tool_form_mcp"}} +
+
+ + +
+
+ + +

Space-separated arguments for the MCP server command.

+
+
+ + +

Leave empty for stdio transport (command-based).

+
+
+{{end}} diff --git a/src/internal/ui/templates/partials/try_panel.html b/src/internal/ui/templates/partials/try_panel.html new file mode 100644 index 0000000..396fad0 --- /dev/null +++ b/src/internal/ui/templates/partials/try_panel.html @@ -0,0 +1,40 @@ +{{define "try_panel"}} +
+
+

Try it

+ {{.Name}} +
+ +
+ {{if .Params}} +
+ {{range .Params}} +
+ + {{if .Description}}

{{.Description}}

{{end}} + +
+ {{end}} +
+ {{else}} +

No parameters.

+ {{end}} + + + + running... + +
+ +
+
+{{end}} diff --git a/src/internal/ui/templates/partials/try_result.html b/src/internal/ui/templates/partials/try_result.html new file mode 100644 index 0000000..84553b0 --- /dev/null +++ b/src/internal/ui/templates/partials/try_result.html @@ -0,0 +1,58 @@ +{{define "try_blocked"}} +
+
+
+ + blocked + {{.Tool}} +
+ {{.DurationMs}}ms +
+
+
{{.Error}}
+
+
+{{end}} + +{{define "try_success"}} +
+
+
+ {{.Icon}} + {{.Status}} + {{.Tool}} +
+ {{.DurationMs}}ms +
+ +
+ + +
+ +
+
+
{{.FormattedOutput}}
+
+ +
+
+ + +{{end}} From 5bebe4d455e8ee1b6d7958227f6680973c4cf07e Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Fri, 8 May 2026 09:37:44 -0400 Subject: [PATCH 37/65] Added some sanitization of tool names --- src/internal/ui/handlers_auth.go | 12 ++++++++---- src/internal/ui/handlers_tools.go | 16 +++++++++++++--- src/internal/ui/handlers_vault.go | 8 ++++---- src/internal/ui/handlers_workflows.go | 2 +- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/internal/ui/handlers_auth.go b/src/internal/ui/handlers_auth.go index 3682d18..c7bf02f 100644 --- a/src/internal/ui/handlers_auth.go +++ b/src/internal/ui/handlers_auth.go @@ -6,6 +6,7 @@ package ui import ( "encoding/json" "fmt" + "html" "net/http" "sort" "strings" @@ -365,6 +366,9 @@ func (s *Server) renderAuthList(w http.ResponseWriter, providers []authProviderV icon = "✗" } + escName := html.EscapeString(p.Name) + escLabel := html.EscapeString(p.StatusLabel) + fmt.Fprintf(w, `
@@ -373,13 +377,13 @@ func (s *Server) renderAuthList(w http.ResponseWriter, providers []authProviderV %s
`, - p.StatusColor, p.StatusColor, icon, p.Name, p.StatusColor, p.StatusLabel) + p.StatusColor, p.StatusColor, icon, escName, p.StatusColor, escLabel) if !p.HasToken { - fmt.Fprintf(w, `factorly auth login %s`, p.Name) + fmt.Fprintf(w, `factorly auth login %s`, escName) } if p.HasToken { - fmt.Fprintf(w, ``, p.Name, p.Name) + fmt.Fprintf(w, ``, escName, escName) } fmt.Fprint(w, `
`) @@ -388,7 +392,7 @@ func (s *Server) renderAuthList(w http.ResponseWriter, providers []authProviderV if len(p.Tools) > 0 { fmt.Fprint(w, `
`) for _, t := range p.Tools { - fmt.Fprintf(w, `%s`, t) + fmt.Fprintf(w, `%s`, html.EscapeString(t)) } fmt.Fprint(w, `
`) } diff --git a/src/internal/ui/handlers_tools.go b/src/internal/ui/handlers_tools.go index 9c8527f..5e43c37 100644 --- a/src/internal/ui/handlers_tools.go +++ b/src/internal/ui/handlers_tools.go @@ -155,9 +155,9 @@ func (s *Server) renderSuccessResponse(w http.ResponseWriter, tool, status, icon var prettyBuf bytes.Buffer if json.Indent(&prettyBuf, []byte(output), "", " ") == nil { formattedOutput = formatJSONHTML(prettyBuf.String()) - } else { - formattedOutput = formatJSONHTML(output) } + // If json.Indent fails, keep the HTMLEscapeString'd version — + // formatJSONHTML is only safe on valid JSON. } tabID := fmt.Sprintf("tab-%d", time.Now().UnixNano()) @@ -244,7 +244,17 @@ func formatJSONHTML(s string) string { i += 4 continue default: - out.WriteByte(ch) + // HTML-escape any unexpected characters as a safety net. + switch ch { + case '<': + out.WriteString("<") + case '>': + out.WriteString(">") + case '&': + out.WriteString("&") + default: + out.WriteByte(ch) + } i++ } } diff --git a/src/internal/ui/handlers_vault.go b/src/internal/ui/handlers_vault.go index 5e98c9d..c937ac9 100644 --- a/src/internal/ui/handlers_vault.go +++ b/src/internal/ui/handlers_vault.go @@ -5,8 +5,8 @@ package ui import ( "fmt" + "html" "net/http" - "strings" ) type vaultSection struct { @@ -139,7 +139,7 @@ func (s *Server) renderVaultKeys(w http.ResponseWriter) { fmt.Fprint(w, `
empty
`) } else { for _, key := range sec.Keys { - escapedKey := strings.ReplaceAll(key, `"`, `"`) + esc := html.EscapeString(key) fmt.Fprintf(w, `
%s
@@ -147,10 +147,10 @@ func (s *Server) renderVaultKeys(w http.ResponseWriter) {
-
`, key, escapedKey, sec.Scope, escapedKey) +
`, esc, esc, sec.Scope, esc) } } fmt.Fprint(w, `
`) diff --git a/src/internal/ui/handlers_workflows.go b/src/internal/ui/handlers_workflows.go index 4f6fb2c..94e887f 100644 --- a/src/internal/ui/handlers_workflows.go +++ b/src/internal/ui/handlers_workflows.go @@ -327,7 +327,7 @@ func (s *Server) handleWorkflowRun(w http.ResponseWriter, r *http.Request) { %s %s %s -
`, iconBg, icon, step.Tool, dur) +
`, iconBg, icon, template.HTMLEscapeString(step.Tool), dur) if step.Error != "" { fmt.Fprintf(w, `
%s
`, template.HTMLEscapeString(step.Error)) From 22ae98339c6421f65f54848cc3d2d9dea57b7cf5 Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Fri, 8 May 2026 10:22:00 -0400 Subject: [PATCH 38/65] Security: bind localhost by default, block SSRF and path traversal in OpenAPI import Harden network and file access across the UI and serve commands: - Bind localhost by default: Both `factorly ui` and `factorly serve` now bind to 127.0.0.1 instead of 0.0.0.0. A new `--host` flag (defaulting to 127.0.0.1) controls the bind address on both commands. Users who need network access (containers, port-forwarding) can pass `--host 0.0.0.0`. - Unify serve flags: Replace `factorly serve --http :3000` with `--host`/`--port` flags matching the `factorly ui` pattern. The legacy `--http` flag is preserved (hidden) for backwards compatibility. - Block SSRF in OpenAPI import: Add `checkSpecURL()` to block requests to cloud metadata endpoints (169.254.169.254, metadata.google.internal), localhost/loopback, and private/link-local IPs when fetching remote specs. - Restrict local file reads: Add `BaseDir` option to `GenerateOpts`. The UI import handlers pass the project directory as a sandbox root; `readSpec()` validates the resolved path (after `EvalSymlinks` to prevent symlink traversal) stays within that directory. CLI callers remain unrestricted. --- docs/cli-reference.md | 21 +++++------ docs/examples/25-mcp-http-remote.md | 10 +++--- docs/examples/26-built-in-tools.md | 2 +- docs/getting-started.md | 4 +-- src/cmd/factorly/serve_cmd.go | 24 ++++++++++--- src/cmd/factorly/ui_cmd.go | 5 +-- src/internal/openapi/openapi.go | 53 +++++++++++++++++++++++++--- src/internal/openapi/openapi_test.go | 6 ++-- src/internal/ui/handlers_import.go | 19 ++++++++-- 9 files changed, 109 insertions(+), 35 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 16d2c48..671ebfd 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -4,7 +4,7 @@ ```bash factorly serve # start MCP server (stdio) -factorly serve --http :3000 # start MCP server (HTTP at /mcp) +factorly serve --port 3000 # start MCP server (HTTP at /mcp) factorly serve --http-token # HTTP with Bearer token auth factorly serve --http-token '{{vault:HTTP_TOKEN}}' # token from vault factorly init # create .factorly/factorly.yaml (interactive) @@ -257,7 +257,8 @@ factorly wrap --url http://localhost:3001/mcp --rate-limit # rate limit (e.g. "100/hour") --max-output # max output bytes per call (default: 50000) --compress # compression hints: "json", "logs", or "all" (default: "all") ---http # serve wrapped server over HTTP (e.g. ":3000") +--host # address to bind (default: 127.0.0.1) +--port # port for HTTP mode --http-token # Bearer token for HTTP mode --env-isolation # "strict" for minimal env, default inherits parent --env KEY=VALUE # set env var (repeatable; supports {{env:VAR}} and {{vault:KEY}}) @@ -285,7 +286,7 @@ Factorly ships with governed alternatives to common agent tools. These are avail ### Context-aware In **stdio mode** (default): all 5 tools available. -In **HTTP mode** (`--http`): only `factorly.fetch` — local tools don't make sense on a remote server. +In **HTTP mode** (`--port`): only `factorly.fetch` — local tools don't make sense on a remote server. ### Safety guards @@ -331,7 +332,7 @@ disable_builtins: true ## HTTP server authentication -When running `factorly serve --http`, you can secure the endpoint with a Bearer token. The token is resolved in order: +When running `factorly serve --port`, you can secure the endpoint with a Bearer token. The token is resolved in order: 1. `--http-token` flag 2. `FACTORLY_HTTP_TOKEN` environment variable @@ -340,25 +341,25 @@ Both support `{{vault:KEY}}` references — the vault is opened automatically if ```bash # Plain token (visible in ps output — use for dev only) -factorly serve --http :3000 --http-token mytoken +factorly serve --port 3000 --http-token mytoken # Environment variable (better for CI/deployment) -FACTORLY_HTTP_TOKEN=mytoken factorly serve --http :3000 +FACTORLY_HTTP_TOKEN=mytoken factorly serve --port 3000 # Vault reference (best — token encrypted at rest) factorly vault set HTTP_TOKEN "my-secret-token" -factorly serve --http :3000 --http-token '{{vault:HTTP_TOKEN}}' +factorly serve --port 3000 --http-token '{{vault:HTTP_TOKEN}}' # Vault ref via env var -FACTORLY_HTTP_TOKEN='{{vault:HTTP_TOKEN}}' factorly serve --http :3000 +FACTORLY_HTTP_TOKEN='{{vault:HTTP_TOKEN}}' factorly serve --port 3000 ``` When a token is set, all HTTP requests must include `Authorization: Bearer `. Requests without a valid token receive a 401 response. A warning is printed to stderr when HTTP mode starts without any token configured. -**Note:** If Factorly is running inside a container (Docker, devcontainer, Codespace), use the host-accessible address — not `localhost`. For Docker, this is typically `host.docker.internal`: +By default, the server binds to `127.0.0.1` (localhost only). If Factorly is running inside a container (Docker, devcontainer, Codespace), bind to all interfaces with `--host 0.0.0.0`: ```bash -factorly serve --http 0.0.0.0:3000 --http-token '{{vault:HTTP_TOKEN}}' +factorly serve --host 0.0.0.0 --port 3000 --http-token '{{vault:HTTP_TOKEN}}' factorly sync --http host.docker.internal:3000 --token '{{vault:HTTP_TOKEN}}' ``` diff --git a/docs/examples/25-mcp-http-remote.md b/docs/examples/25-mcp-http-remote.md index c5a4193..39791e3 100644 --- a/docs/examples/25-mcp-http-remote.md +++ b/docs/examples/25-mcp-http-remote.md @@ -6,11 +6,11 @@ Serve Factorly's tools over HTTP so remote agents (or agents inside containers) ```bash # Start the HTTP server with token auth -factorly serve --http :3000 --http-token mytoken +factorly serve --port 3000 --http-token mytoken # Or use a vault-stored token (recommended) factorly vault set HTTP_TOKEN my-secret-token -factorly serve --http :3000 --http-token '{{vault:HTTP_TOKEN}}' +factorly serve --port 3000 --http-token '{{vault:HTTP_TOKEN}}' ``` ## Config @@ -32,11 +32,11 @@ Connect Claude Code to the HTTP server by adding to `.mcp.json`: ### Docker setup -When Factorly runs inside a container, use `host.docker.internal` from the host or bind to `0.0.0.0`: +When Factorly runs inside a container, bind to all interfaces with `--host 0.0.0.0`: ```bash # Inside the container -factorly serve --http 0.0.0.0:3000 --http-token '{{vault:HTTP_TOKEN}}' +factorly serve --host 0.0.0.0 --port 3000 --http-token '{{vault:HTTP_TOKEN}}' ``` ```json @@ -60,7 +60,7 @@ factorly sync --http host.docker.internal:3000 --token '{{vault:HTTP_TOKEN}}' ## What happens -1. `factorly serve --http :3000` starts an HTTP server at `/mcp` using the MCP Streamable HTTP transport. +1. `factorly serve --port 3000` starts an HTTP server at `/mcp` using the MCP Streamable HTTP transport. By default it binds to `127.0.0.1` (localhost only) — use `--host 0.0.0.0` for containers. 2. `--http-token` enables Bearer token authentication. Every request must include `Authorization: Bearer ` or it receives a 401. 3. All configured tools are exposed over HTTP. Built-in tools (`factorly.shell`, `factorly.write_file`, etc.) are hidden in HTTP mode since local operations don't make sense on a remote server — only `factorly.fetch` remains available. 4. Use HTTP mode when: the agent runs in a different process/container, you need multiple agents sharing one server, or stdio isn't available. Use stdio mode (the default) for local, single-agent setups — it's simpler and has no auth overhead. diff --git a/docs/examples/26-built-in-tools.md b/docs/examples/26-built-in-tools.md index f8c754b..7dc7746 100644 --- a/docs/examples/26-built-in-tools.md +++ b/docs/examples/26-built-in-tools.md @@ -73,7 +73,7 @@ disable_builtins: true 1. Built-in tools are registered automatically when Factorly starts. They appear in `factorly tools` alongside your configured tools. 2. Each built-in has default oversight rules (confirm prompts, blocked patterns). These apply without any config. 3. You can loosen restrictions using `allow_patterns`, `allow_paths`, or `allow_urls` in a `shadow` block — but only for specific values, not blanket overrides. -4. In HTTP mode (`factorly serve --http`), local tools (`shell`, `read_file`, `write_file`, `clipboard`) are hidden. Only `factorly.fetch` is available since local filesystem operations don't apply to remote servers. +4. In HTTP mode (`factorly serve --port`), local tools (`shell`, `read_file`, `write_file`, `clipboard`) are hidden. Only `factorly.fetch` is available since local filesystem operations don't apply to remote servers. --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 06eac60..403bf1e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -112,10 +112,10 @@ This writes the Factorly MCP server entry into `.mcp.json` (Claude Code), `.curs For remote or shared servers: ```bash -factorly serve --http :3000 +factorly serve --port 3000 ``` -Endpoint at `http://localhost:3000/mcp`. Secure with `--http-token` or `FACTORLY_HTTP_TOKEN`. See [CLI Reference](cli-reference.md) for details. +Endpoint at `http://localhost:3000/mcp`. Secure with `--http-token` or `FACTORLY_HTTP_TOKEN`. See [CLI Reference](cli-reference.md) for details. Use `--host 0.0.0.0` to bind to all interfaces (e.g. for containers). > **Note:** If running inside Docker, use `host.docker.internal` instead of `localhost`. See [CLI Reference](cli-reference.md#http-server-authentication) for container setup. diff --git a/src/cmd/factorly/serve_cmd.go b/src/cmd/factorly/serve_cmd.go index 9ff93c6..2149662 100644 --- a/src/cmd/factorly/serve_cmd.go +++ b/src/cmd/factorly/serve_cmd.go @@ -20,8 +20,12 @@ import ( "github.com/spf13/cobra" ) -var httpAddr string -var httpToken string +var ( + httpHost string + httpPort int + httpAddr string // legacy --http flag + httpToken string +) var serveCmd = &cobra.Command{ Use: "serve", @@ -29,12 +33,20 @@ var serveCmd = &cobra.Command{ Long: `Start an MCP server that exposes all configured Factorly tools to MCP clients like Claude Code and Cursor. -By default, uses stdio transport. Use --http to start an HTTP server instead.`, +By default, uses stdio transport. Use --port to start an HTTP server instead.`, RunE: func(cmd *cobra.Command, args []string) error { if err := checkCommandAllowed("serve"); err != nil { return err } + // --host/--port or legacy --http both enable HTTP mode + if httpPort != 0 && httpAddr == "" { + httpAddr = fmt.Sprintf("%s:%d", httpHost, httpPort) + } if httpAddr != "" { + // Default to localhost when only a port is given (e.g. ":3000") + if strings.HasPrefix(httpAddr, ":") { + httpAddr = "127.0.0.1" + httpAddr + } serveMode = "http" } cfg, reg, err := loadConfig() @@ -181,8 +193,10 @@ func mcpElicitConfirm(ctx context.Context, toolName string, params map[string]st } func init() { - serveCmd.Flags().StringVar(&httpAddr, "http", "", - "start HTTP transport on this address (e.g. :3000) instead of stdio") + serveCmd.Flags().StringVar(&httpHost, "host", "127.0.0.1", "address to bind the HTTP server (use 0.0.0.0 for all interfaces)") + serveCmd.Flags().IntVar(&httpPort, "port", 0, "port for HTTP transport (enables HTTP mode)") + serveCmd.Flags().StringVar(&httpAddr, "http", "", "start HTTP transport on this address (legacy; prefer --host/--port)") serveCmd.Flags().StringVar(&httpToken, "http-token", "", "require Bearer token authentication for HTTP transport") + _ = serveCmd.Flags().MarkHidden("http") } diff --git a/src/cmd/factorly/ui_cmd.go b/src/cmd/factorly/ui_cmd.go index e09bd88..14749bc 100644 --- a/src/cmd/factorly/ui_cmd.go +++ b/src/cmd/factorly/ui_cmd.go @@ -25,6 +25,7 @@ import ( ) var ( + uiHost string uiPort int uiMCP bool uiMCPToken string @@ -39,6 +40,7 @@ var uiCmd = &cobra.Command{ } func init() { + uiCmd.Flags().StringVar(&uiHost, "host", "127.0.0.1", "address to bind the UI server (use 0.0.0.0 for all interfaces)") uiCmd.Flags().IntVar(&uiPort, "port", 3741, "port for the UI server") uiCmd.Flags().BoolVar(&uiMCP, "mcp", false, "also serve MCP endpoint at /mcp") uiCmd.Flags().StringVar(&uiMCPToken, "mcp-token", "", "bearer token for MCP endpoint (required when --mcp is set)") @@ -151,8 +153,7 @@ func runUI(cmd *cobra.Command, args []string) error { // Wrap with host validation + token check handler := hostValidation(tokenValidation(srv.Handler(), token)) - // Bind to all interfaces (needed for container/port-forward scenarios) - addr := fmt.Sprintf(":%d", uiPort) + addr := fmt.Sprintf("%s:%d", uiHost, uiPort) url := fmt.Sprintf("http://localhost:%d/?token=%s", uiPort, token) fmt.Fprintf(cmd.ErrOrStderr(), "Factorly UI running at %s\n", url) diff --git a/src/internal/openapi/openapi.go b/src/internal/openapi/openapi.go index 0573533..63cb0ba 100644 --- a/src/internal/openapi/openapi.go +++ b/src/internal/openapi/openapi.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "net/url" "os" @@ -19,12 +20,13 @@ import ( ) type GenerateOpts struct { - Prefix string // tool name prefix, defaults to slugified info.title + Prefix string // tool name prefix, defaults to slugified info.title + BaseDir string // if set, local file paths are restricted to this directory } // Generate reads an OpenAPI 3.x spec (local file or URL) and returns Factorly tool definitions. func Generate(specPath string, opts GenerateOpts) (map[string]config.ToolConfig, error) { - data, err := readSpec(specPath) + data, err := readSpec(specPath, opts.BaseDir) if err != nil { return nil, err } @@ -305,9 +307,8 @@ func isHTTPMethod(m string) bool { return false } -func readSpec(specPath string) ([]byte, error) { +func readSpec(specPath, baseDir string) ([]byte, error) { if strings.HasPrefix(specPath, "http://") || strings.HasPrefix(specPath, "https://") { - // Validate URL before fetching to prevent SSRF against internal services parsed, err := url.Parse(specPath) if err != nil { return nil, fmt.Errorf("invalid spec URL: %w", err) @@ -315,6 +316,9 @@ func readSpec(specPath string) ([]byte, error) { if parsed.Scheme != "http" && parsed.Scheme != "https" { return nil, fmt.Errorf("unsupported URL scheme %q (only http/https allowed)", parsed.Scheme) } + if err := checkSpecURL(parsed); err != nil { + return nil, err + } resp, err := http.Get(parsed.String()) //nolint:gosec,noctx // user-provided URL is intentional for spec import if err != nil { return nil, fmt.Errorf("fetching spec from %s: %w", specPath, err) @@ -335,9 +339,48 @@ func readSpec(specPath string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("resolving spec path: %w", err) } - data, err := os.ReadFile(absPath) // #nosec G304 -- user explicitly provides spec file path via CLI/UI + // When baseDir is set, restrict reads to that directory. + if baseDir != "" { + absBase, err := filepath.Abs(baseDir) + if err != nil { + return nil, fmt.Errorf("resolving base dir: %w", err) + } + // EvalSymlinks resolves symlink traversal before the prefix check. + resolved, err := filepath.EvalSymlinks(absPath) + if err != nil { + return nil, fmt.Errorf("reading spec: %w", err) + } + if !strings.HasPrefix(resolved, absBase+string(filepath.Separator)) && resolved != absBase { + return nil, fmt.Errorf("spec path %q is outside the project directory", specPath) + } + } + data, err := os.ReadFile(absPath) // #nosec G304 -- validated against baseDir above when called from UI if err != nil { return nil, fmt.Errorf("reading spec: %w", err) } return data, nil } + +// checkSpecURL blocks requests to internal/private network targets. +func checkSpecURL(u *url.URL) error { + host := u.Hostname() + + // Block cloud metadata endpoints + if host == "169.254.169.254" || host == "metadata.google.internal" { + return fmt.Errorf("spec URL blocked: cloud metadata endpoint") + } + + // Block localhost/loopback + if host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1" { + return fmt.Errorf("spec URL blocked: localhost access denied") + } + + // Block private/link-local IPs + if ip := net.ParseIP(host); ip != nil { + if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + return fmt.Errorf("spec URL blocked: private network access denied") + } + } + + return nil +} diff --git a/src/internal/openapi/openapi_test.go b/src/internal/openapi/openapi_test.go index a0fd4ff..61debf6 100644 --- a/src/internal/openapi/openapi_test.go +++ b/src/internal/openapi/openapi_test.go @@ -368,7 +368,7 @@ paths: } func TestReadSpec_RejectsInvalidScheme(t *testing.T) { - _, err := readSpec("ftp://evil.example.com/spec.yaml") + _, err := readSpec("ftp://evil.example.com/spec.yaml", "") // ftp is not http/https, so it falls through to file read which should fail if err == nil { t.Fatal("expected error for non-http URL treated as file path") @@ -390,7 +390,7 @@ paths: specPath := filepath.Join(dir, "spec.yaml") _ = os.WriteFile(specPath, []byte(specContent), 0o644) - data, err := readSpec(specPath) + data, err := readSpec(specPath, "") if err != nil { t.Fatalf("readSpec failed: %v", err) } @@ -401,7 +401,7 @@ paths: func TestReadSpec_TraversalPath(t *testing.T) { // This should resolve via filepath.Abs and fail because the file doesn't exist - _, err := readSpec("../../../etc/passwd") + _, err := readSpec("../../../etc/passwd", "") if err == nil { t.Fatal("expected error for traversal path") } diff --git a/src/internal/ui/handlers_import.go b/src/internal/ui/handlers_import.go index 273b0f9..18d7b13 100644 --- a/src/internal/ui/handlers_import.go +++ b/src/internal/ui/handlers_import.go @@ -36,7 +36,10 @@ func (s *Server) handleImportPreview(w http.ResponseWriter, r *http.Request) { return } - tools, err := openapi.Generate(specURL, openapi.GenerateOpts{Prefix: prefix}) + tools, err := openapi.Generate(specURL, openapi.GenerateOpts{ + Prefix: prefix, + BaseDir: s.projectDir(), + }) if err != nil { s.renderPartial(w, "import_error", map[string]any{"Message": "Error: " + err.Error()}) return @@ -112,7 +115,10 @@ func (s *Server) handleImportConfirm(w http.ResponseWriter, r *http.Request) { } // Re-generate from spec - tools, err := openapi.Generate(specURL, openapi.GenerateOpts{Prefix: prefix}) + tools, err := openapi.Generate(specURL, openapi.GenerateOpts{ + Prefix: prefix, + BaseDir: s.projectDir(), + }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -175,6 +181,15 @@ func (s *Server) handleImportConfirm(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/tools", http.StatusFound) } +// projectDir returns the directory containing the config file, used as the +// sandbox root for local file operations like spec import. +func (s *Server) projectDir() string { + if s.cfgPath != "" { + return filepath.Dir(s.cfgPath) + } + return "." +} + func writeFileCreate(path string, data []byte) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err From b9030ee719fcef995336d184bcb0ed0c437ebaff Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Fri, 8 May 2026 10:24:59 -0400 Subject: [PATCH 39/65] Fix Telegram template auth, resolve vault refs in REST paths, clear vault form on success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Telegram template: Remove incorrect Bearer auth — Telegram uses the bot token in the URL path, not an Authorization header. Replace /bot{{token}}/... (which required a manual token parameter) with /bot{{vault:TELEGRAM_BOT_TOKEN}}/... resolved at registration time. Remove the now-unnecessary token parameter from all 6 tools. - Resolve vault refs in REST path: Both bootstrapProviders and the UI's registerRESTProvider now resolve {{vault:KEY}} references in path (previously only base_url, auth, and headers were resolved). This enables patterns like /bot{{vault:TOKEN}}/method. - Clear vault form on submit: Add hx-on::after-request handler to reset the Add Secret form after a successful POST, preventing accidental double-submission with stale values. - Relax template test: Allow REST tools without an auth block when they use {{vault:}} references in the path (alternative auth pattern). --- src/cmd/factorly/main.go | 2 +- src/internal/templates/telegram.go | 4 +- src/internal/templates/templates_test.go | 2 +- src/internal/templates/yaml/telegram.yaml | 55 +++-------------------- src/internal/ui/server.go | 2 +- src/internal/ui/templates/vault.html | 2 +- 6 files changed, 12 insertions(+), 55 deletions(-) diff --git a/src/cmd/factorly/main.go b/src/cmd/factorly/main.go index b7b0cd7..4208b79 100644 --- a/src/cmd/factorly/main.go +++ b/src/cmd/factorly/main.go @@ -616,7 +616,7 @@ func bootstrapProviders(cfg *config.Config, reg *registry.Registry, confirmFn .. restDef := provider.RESTToolDef{ Method: toolCfg.Method, BaseURL: resolveVaultRefTracked(resolver, toolCfg.BaseURL, &vaultKeys), - Path: toolCfg.Path, + Path: resolveVaultRefTracked(resolver, toolCfg.Path, &vaultKeys), Body: toolCfg.Body, Headers: resolveVaultMap(resolver, toolCfg.Headers), } diff --git a/src/internal/templates/telegram.go b/src/internal/templates/telegram.go index 1fe413a..e4e2a31 100644 --- a/src/internal/templates/telegram.go +++ b/src/internal/templates/telegram.go @@ -15,8 +15,8 @@ func Telegram() *Template { DisplayName: "Telegram", Description: "Bot messaging, channels, and notifications via phone", Category: "engineering", - AuthType: "bearer", - AuthGuide: "Create a bot with @BotFather on Telegram — it will give you the token", + AuthType: "vault", + AuthGuide: "Create a bot with @BotFather on Telegram, then run: factorly vault set TELEGRAM_BOT_TOKEN ", VaultKey: "TELEGRAM_BOT_TOKEN", YAML: telegramYAML, } diff --git a/src/internal/templates/templates_test.go b/src/internal/templates/templates_test.go index 2a524e1..bebb94b 100644 --- a/src/internal/templates/templates_test.go +++ b/src/internal/templates/templates_test.go @@ -61,7 +61,7 @@ func TestAllTemplatesYAMLValid(t *testing.T) { if tc.Method == "" { t.Errorf("template %q tool %s: empty method", tmpl.Name, name) } - if tc.Auth == nil { + if tc.Auth == nil && !strings.Contains(tc.Path, "{{vault:") { t.Errorf("template %q tool %s: missing auth", tmpl.Name, name) } case "cli": diff --git a/src/internal/templates/yaml/telegram.yaml b/src/internal/templates/yaml/telegram.yaml index 43efc01..7374c90 100644 --- a/src/internal/templates/yaml/telegram.yaml +++ b/src/internal/templates/yaml/telegram.yaml @@ -3,15 +3,8 @@ telegram.send_message: description: Send a text message to a chat base_url: "https://api.telegram.org" method: POST - path: "/bot{{token}}/sendMessage" - auth: - type: bearer - token: "{{vault:TELEGRAM_BOT_TOKEN}}" + path: "/bot{{vault:TELEGRAM_BOT_TOKEN}}/sendMessage" parameters: - - name: token - description: "Bot token (auto-filled from vault)" - required: true - in: path - name: chat_id description: "Chat ID or @channel_username" required: true @@ -31,15 +24,8 @@ telegram.get_updates: description: "Get incoming updates (messages, commands, etc.)" base_url: "https://api.telegram.org" method: POST - path: "/bot{{token}}/getUpdates" - auth: - type: bearer - token: "{{vault:TELEGRAM_BOT_TOKEN}}" + path: "/bot{{vault:TELEGRAM_BOT_TOKEN}}/getUpdates" parameters: - - name: token - description: Bot token - required: true - in: path - name: offset description: Update offset for pagination in: body @@ -52,15 +38,8 @@ telegram.send_document: description: Send a file to a chat base_url: "https://api.telegram.org" method: POST - path: "/bot{{token}}/sendDocument" - auth: - type: bearer - token: "{{vault:TELEGRAM_BOT_TOKEN}}" + path: "/bot{{vault:TELEGRAM_BOT_TOKEN}}/sendDocument" parameters: - - name: token - description: Bot token - required: true - in: path - name: chat_id description: "Chat ID or @channel_username" required: true @@ -80,15 +59,8 @@ telegram.get_chat: description: Get information about a chat base_url: "https://api.telegram.org" method: POST - path: "/bot{{token}}/getChat" - auth: - type: bearer - token: "{{vault:TELEGRAM_BOT_TOKEN}}" + path: "/bot{{vault:TELEGRAM_BOT_TOKEN}}/getChat" parameters: - - name: token - description: Bot token - required: true - in: path - name: chat_id description: "Chat ID or @channel_username" required: true @@ -99,15 +71,8 @@ telegram.send_photo: description: Send a photo to a chat base_url: "https://api.telegram.org" method: POST - path: "/bot{{token}}/sendPhoto" - auth: - type: bearer - token: "{{vault:TELEGRAM_BOT_TOKEN}}" + path: "/bot{{vault:TELEGRAM_BOT_TOKEN}}/sendPhoto" parameters: - - name: token - description: Bot token - required: true - in: path - name: chat_id description: "Chat ID or @channel_username" required: true @@ -127,12 +92,4 @@ telegram.get_me: description: Get information about the bot base_url: "https://api.telegram.org" method: POST - path: "/bot{{token}}/getMe" - auth: - type: bearer - token: "{{vault:TELEGRAM_BOT_TOKEN}}" - parameters: - - name: token - description: Bot token - required: true - in: path + path: "/bot{{vault:TELEGRAM_BOT_TOKEN}}/getMe" diff --git a/src/internal/ui/server.go b/src/internal/ui/server.go index d411da0..7710f6a 100644 --- a/src/internal/ui/server.go +++ b/src/internal/ui/server.go @@ -314,7 +314,7 @@ func (s *Server) registerRESTProvider(name string, tc config.ToolConfig, vaultKe def := provider.RESTToolDef{ Method: tc.Method, BaseURL: s.resolveRefT(tc.BaseURL, vaultKeys), - Path: tc.Path, + Path: s.resolveRefT(tc.Path, vaultKeys), Body: tc.Body, Headers: s.resolveRefMapTracked(tc.Headers, vaultKeys), } diff --git a/src/internal/ui/templates/vault.html b/src/internal/ui/templates/vault.html index 52a4ecd..106574b 100644 --- a/src/internal/ui/templates/vault.html +++ b/src/internal/ui/templates/vault.html @@ -15,7 +15,7 @@

Vault

Add Secret

-
+
Date: Fri, 8 May 2026 12:17:55 -0400 Subject: [PATCH 40/65] Add body_type (json/form/raw), param in: field, config reload, shared vault resolver, verbose REST logging Fix REST body building for single-param POST tools (Telegram chat_id), add explicit body type control, preserve param location on save, unify vault ref resolution with the shared resolver, add config reload button, and enhance REST verbose logging. Body type support: - Add body_type field to ToolConfig and RESTToolDef: "json" (default), "form", "raw" - json: always builds JSON object from named body params (fixes single body param being sent as raw value instead of {"chat_id":"123"}) - form: sends as application/x-www-form-urlencoded - raw: sends first body param value as-is (for pre-formatted JSON/XML) - Content-Type auto-set: application/json for json/raw, form-urlencoded for form - UI: body type dropdown (JSON/Form/Raw) next to body textarea - 6 new body type tests: single JSON, multi JSON, typed params, form, raw, default-is-JSON Param in: field preservation: - Tool save handler now parses param_in_N from form into ParamConfig.In - UI: "in" dropdown (auto/query/path/body/header) per parameter - Fixes tools losing in:body on save, causing POST params to go to query Shared vault resolver: - Replace ad-hoc resolveRefTracked (only handled {{vault:KEY}} as whole string) with delegation to vault.Resolver.Resolve/ResolveTracked - Supports inline refs (/bot{{vault:TOKEN}}/method), multiple backends ({{vault:}}, {{op:}}), defaults (fallback) - Cache resolver from bootstrapProviders, pass to UI server via Options - resolve.go rewritten: 5 functions now delegate to s.resolver Config reload button: - POST /reload re-reads config from disk, diffs tools, applies deltas via registerTool/unregisterTool - Lucide refresh-cw icon in header nav, triggers HX-Refresh on success - Shows added/updated/removed counts Verbose REST logging: - Log all request headers - Log body content with type and size (truncated at 500 chars) - Helps debug body building issues like the Telegram chat_id bug --- src/cmd/factorly/main.go | 19 +- src/cmd/factorly/ui_cmd.go | 1 + src/internal/config/config.go | 13 +- src/internal/provider/rest.go | 110 +++++---- src/internal/provider/rest_test.go | 269 +++++++++++++++++++++-- src/internal/ui/handlers_test.go | 8 +- src/internal/ui/handlers_tools.go | 2 + src/internal/ui/resolve.go | 63 +++--- src/internal/ui/server.go | 63 +++++- src/internal/ui/templates/layout.html | 9 +- src/internal/ui/templates/tool_edit.html | 36 ++- src/test/integration_test.go | 1 + 12 files changed, 477 insertions(+), 117 deletions(-) diff --git a/src/cmd/factorly/main.go b/src/cmd/factorly/main.go index 4208b79..c9e4d77 100644 --- a/src/cmd/factorly/main.go +++ b/src/cmd/factorly/main.go @@ -568,6 +568,13 @@ func loadConfig() (*config.Config, *registry.Registry, error) { return cfg, reg, nil } +var cachedResolver *vault.Resolver + +// getCachedResolver returns the resolver from the last bootstrapProviders call. +func getCachedResolver() *vault.Resolver { + return cachedResolver +} + // bootstrapProviders opens the vault if needed, creates providers, and // wires everything into a proxy. Takes config and registry from loadConfig(). // confirmFn is used for shadow confirm prompts — nil uses the default CLI prompt. @@ -577,6 +584,7 @@ func bootstrapProviders(cfg *config.Config, reg *registry.Registry, confirmFn .. if err != nil { return nil, err } + cachedResolver = resolver providers := make(map[string]provider.Provider) cliTools := make(map[string]provider.CLIToolDef) @@ -614,11 +622,12 @@ func bootstrapProviders(cfg *config.Config, reg *registry.Registry, confirmFn .. vlog(" registered cli tool: %s", name) case "rest": restDef := provider.RESTToolDef{ - Method: toolCfg.Method, - BaseURL: resolveVaultRefTracked(resolver, toolCfg.BaseURL, &vaultKeys), - Path: resolveVaultRefTracked(resolver, toolCfg.Path, &vaultKeys), - Body: toolCfg.Body, - Headers: resolveVaultMap(resolver, toolCfg.Headers), + Method: toolCfg.Method, + BaseURL: resolveVaultRefTracked(resolver, toolCfg.BaseURL, &vaultKeys), + Path: resolveVaultRefTracked(resolver, toolCfg.Path, &vaultKeys), + Body: toolCfg.Body, + BodyType: toolCfg.BodyType, + Headers: resolveVaultMap(resolver, toolCfg.Headers), } if toolCfg.Auth != nil { authDef := &provider.AuthDef{ diff --git a/src/cmd/factorly/ui_cmd.go b/src/cmd/factorly/ui_cmd.go index 14749bc..3a0e1e9 100644 --- a/src/cmd/factorly/ui_cmd.go +++ b/src/cmd/factorly/ui_cmd.go @@ -110,6 +110,7 @@ func runUI(cmd *cobra.Command, args []string) error { Registry: reg, Proxy: p, Vault: vaultBackend, + Resolver: getCachedResolver(), ProjectVault: projectVault, GlobalVault: globalVault, Activity: activity, diff --git a/src/internal/config/config.go b/src/internal/config/config.go index 3e2ff09..b943293 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -61,12 +61,13 @@ type ToolConfig struct { URL string `yaml:"url,omitempty"` // MCP HTTP transport URL // REST fields - BaseURL string `yaml:"base_url,omitempty"` - Method string `yaml:"method,omitempty"` - Path string `yaml:"path,omitempty"` - Body string `yaml:"body,omitempty"` // JSON body template with {{param}} placeholders - Headers map[string]string `yaml:"headers,omitempty"` - Auth *AuthConfig `yaml:"auth,omitempty"` + BaseURL string `yaml:"base_url,omitempty"` + Method string `yaml:"method,omitempty"` + Path string `yaml:"path,omitempty"` + Body string `yaml:"body,omitempty"` // JSON body template with {{param}} placeholders + BodyType string `yaml:"body_type,omitempty"` // "json" (default), "form", "raw" + Headers map[string]string `yaml:"headers,omitempty"` + Auth *AuthConfig `yaml:"auth,omitempty"` // Workflow fields Steps []StepConfig `yaml:"steps,omitempty"` diff --git a/src/internal/provider/rest.go b/src/internal/provider/rest.go index f18df30..92418ea 100644 --- a/src/internal/provider/rest.go +++ b/src/internal/provider/rest.go @@ -46,14 +46,15 @@ type AuthDef struct { } type RESTToolDef struct { - Method string - BaseURL string - Path string - Body string // JSON body template with {{param}} placeholders - Headers map[string]string - Auth *AuthDef - Params []RESTParamDef - Timeout time.Duration + Method string + BaseURL string + Path string + Body string // JSON body template with {{param}} placeholders + BodyType string // "json" (default), "form", "raw" + Headers map[string]string + Auth *AuthDef + Params []RESTParamDef + Timeout time.Duration } type RESTProvider struct { @@ -192,40 +193,53 @@ func (p *RESTProvider) Execute(toolName string, params map[string]string) (*Resu // Apply remaining defaults for unmatched {{param|default}} placeholders bodyContent = applyBodyDefaults(bodyContent) - } else if len(bodyParams) == 1 { - for _, v := range bodyParams { - bodyContent = v + } else if len(bodyParams) > 0 { + bodyType := def.BodyType + if bodyType == "" { + bodyType = "json" // default } - } else if len(bodyParams) > 1 { - obj := make(map[string]json.RawMessage, len(bodyParams)) - for k, v := range bodyParams { - switch paramType[k] { - case "integer", "number": - // Pass numeric values unquoted - obj[k] = json.RawMessage(v) - case "boolean": - // Pass boolean values unquoted - obj[k] = json.RawMessage(v) - case "json": - // Pass raw JSON through (arrays, objects) - obj[k] = json.RawMessage(v) - default: - // "string" or unspecified — auto-detect: - // if valid JSON (array, object, number, bool), pass through; - // otherwise quote as string - if json.Valid([]byte(v)) && (v[0] == '[' || v[0] == '{') { + + switch bodyType { + case "raw": + // Single raw body — use the first (or only) param value as-is + for _, v := range bodyParams { + bodyContent = v + break + } + + case "form": + // URL-encoded form body + form := url.Values{} + for k, v := range bodyParams { + form.Set(k, v) + } + bodyContent = form.Encode() + + default: // "json" + obj := make(map[string]json.RawMessage, len(bodyParams)) + for k, v := range bodyParams { + switch paramType[k] { + case "integer", "number": + obj[k] = json.RawMessage(v) + case "boolean": obj[k] = json.RawMessage(v) - } else { - quoted, _ := json.Marshal(v) - obj[k] = json.RawMessage(quoted) + case "json": + obj[k] = json.RawMessage(v) + default: + if json.Valid([]byte(v)) && len(v) > 0 && (v[0] == '[' || v[0] == '{') { + obj[k] = json.RawMessage(v) + } else { + quoted, _ := json.Marshal(v) + obj[k] = json.RawMessage(quoted) + } } } + data, err := json.Marshal(obj) + if err != nil { + return nil, fmt.Errorf("rest provider: building request body for tool %q: %w", toolName, err) + } + bodyContent = string(data) } - data, err := json.Marshal(obj) - if err != nil { - return nil, fmt.Errorf("rest provider: building request body for tool %q: %w", toolName, err) - } - bodyContent = string(data) } // Substitute path parameters @@ -314,12 +328,32 @@ func (p *RESTProvider) Execute(toolName string, params map[string]string) (*Resu req.Header.Set(name, value) } if bodyContent != "" && req.Header.Get("Content-Type") == "" { - req.Header.Set("Content-Type", "application/json") + if def.BodyType == "form" { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } else { + req.Header.Set("Content-Type", "application/json") + } } // Verbose logging if p.Verbose { fmt.Fprintf(os.Stderr, "[rest] %s %s\n", req.Method, req.URL) + for k, vals := range req.Header { + for _, v := range vals { + fmt.Fprintf(os.Stderr, "[rest] header: %s: %s\n", k, v) + } + } + if bodyContent != "" { + preview := bodyContent + if len(preview) > 500 { + preview = preview[:500] + "..." + } + bt := def.BodyType + if bt == "" { + bt = "json" + } + fmt.Fprintf(os.Stderr, "[rest] body (%s, %d bytes): %s\n", bt, len(bodyContent), preview) + } if fileParam != "" { absFile, _ := filepath.Abs(fileParam) fi, _ := os.Stat(absFile) // #nosec G304 -- verbose logging for user-provided file param diff --git a/src/internal/provider/rest_test.go b/src/internal/provider/rest_test.go index 1d84bf0..7570352 100644 --- a/src/internal/provider/rest_test.go +++ b/src/internal/provider/rest_test.go @@ -6,6 +6,7 @@ package provider import ( "bytes" "encoding/base64" + "encoding/json" "fmt" "io" "net/http" @@ -219,10 +220,11 @@ func TestRESTBodyParam(t *testing.T) { p := NewREST(map[string]RESTToolDef{ "test": { - Method: "POST", - BaseURL: srv.URL, - Path: "/items", - Params: []RESTParamDef{{Name: "body", In: "body"}}, + Method: "POST", + BaseURL: srv.URL, + Path: "/items", + BodyType: "raw", + Params: []RESTParamDef{{Name: "body", In: "body"}}, }, }, nil) _ = p.Setup() @@ -281,9 +283,10 @@ func TestRESTDefaultParamRoutingPOST(t *testing.T) { p := NewREST(map[string]RESTToolDef{ "test": { - Method: "POST", - BaseURL: srv.URL, - Path: "/items", + Method: "POST", + BaseURL: srv.URL, + Path: "/items", + BodyType: "raw", }, }, nil) _ = p.Setup() @@ -291,7 +294,7 @@ func TestRESTDefaultParamRoutingPOST(t *testing.T) { _, _ = p.Execute("test", map[string]string{"data": `{"x":1}`}) if capturedBody != `{"x":1}` { - t.Errorf("expected POST param as body, got %q", capturedBody) + t.Errorf("expected POST param as raw body, got %q", capturedBody) } } @@ -316,9 +319,10 @@ func TestRESTMixedParams(t *testing.T) { p := NewREST(map[string]RESTToolDef{ "test": { - Method: "POST", - BaseURL: srv.URL, - Path: "/orgs/{{orgId}}/items", + Method: "POST", + BaseURL: srv.URL, + Path: "/orgs/{{orgId}}/items", + BodyType: "raw", Params: []RESTParamDef{ {Name: "orgId", In: "path", Required: true}, {Name: "limit", In: "query"}, @@ -540,11 +544,12 @@ func TestRESTContentTypeOverride(t *testing.T) { p := NewREST(map[string]RESTToolDef{ "test": { - Method: "POST", - BaseURL: srv.URL, - Path: "/", - Headers: map[string]string{"Content-Type": "text/plain"}, - Params: []RESTParamDef{{Name: "body", In: "body"}}, + Method: "POST", + BaseURL: srv.URL, + Path: "/", + BodyType: "raw", + Headers: map[string]string{"Content-Type": "text/plain"}, + Params: []RESTParamDef{{Name: "body", In: "body"}}, }, }, nil) _ = p.Setup() @@ -1097,3 +1102,235 @@ func TestRESTPathParamsAutoDetect(t *testing.T) { t.Errorf("expected /repos/factorly-dev/factorly, got %s", receivedPath) } } + +// --- Body Type tests --- + +func TestRESTBodyType_JSONSingleParam(t *testing.T) { + var capturedBody string + var capturedCT string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedCT = r.Header.Get("Content-Type") + body, _ := io.ReadAll(r.Body) + capturedBody = string(body) + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + + p := NewREST(map[string]RESTToolDef{ + "test": { + Method: "POST", + BaseURL: srv.URL, + Path: "/getChat", + Params: []RESTParamDef{{Name: "chat_id", In: "body", Required: true}}, + // BodyType defaults to "json" + }, + }, nil) + _ = p.Setup() + + _, err := p.Execute("test", map[string]string{"chat_id": "123456"}) + if err != nil { + t.Fatal(err) + } + if capturedBody != `{"chat_id":"123456"}` { + t.Errorf("expected JSON object, got %q", capturedBody) + } + if capturedCT != "application/json" { + t.Errorf("expected application/json, got %q", capturedCT) + } +} + +func TestRESTBodyType_JSONMultipleParams(t *testing.T) { + var capturedBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + capturedBody = string(body) + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + + p := NewREST(map[string]RESTToolDef{ + "test": { + Method: "POST", + BaseURL: srv.URL, + Path: "/sendMessage", + BodyType: "json", + Params: []RESTParamDef{ + {Name: "chat_id", In: "body", Required: true}, + {Name: "text", In: "body", Required: true}, + }, + }, + }, nil) + _ = p.Setup() + + _, err := p.Execute("test", map[string]string{"chat_id": "123", "text": "hello"}) + if err != nil { + t.Fatal(err) + } + + // Parse as JSON to check keys (order may vary) + var parsed map[string]any + if err := json.Unmarshal([]byte(capturedBody), &parsed); err != nil { + t.Fatalf("body not valid JSON: %s", capturedBody) + } + if parsed["chat_id"] != "123" { + t.Errorf("chat_id: expected '123', got %v", parsed["chat_id"]) + } + if parsed["text"] != "hello" { + t.Errorf("text: expected 'hello', got %v", parsed["text"]) + } +} + +func TestRESTBodyType_JSONWithTypes(t *testing.T) { + var capturedBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + capturedBody = string(body) + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + + p := NewREST(map[string]RESTToolDef{ + "test": { + Method: "POST", + BaseURL: srv.URL, + Path: "/", + Params: []RESTParamDef{ + {Name: "count", In: "body", Type: "integer"}, + {Name: "active", In: "body", Type: "boolean"}, + {Name: "tags", In: "body", Type: "json"}, + {Name: "name", In: "body"}, + }, + }, + }, nil) + _ = p.Setup() + + _, err := p.Execute("test", map[string]string{ + "count": "42", + "active": "true", + "tags": `["a","b"]`, + "name": "test", + }) + if err != nil { + t.Fatal(err) + } + + var parsed map[string]json.RawMessage + if err := json.Unmarshal([]byte(capturedBody), &parsed); err != nil { + t.Fatalf("body not valid JSON: %s", capturedBody) + } + // integer: unquoted + if string(parsed["count"]) != "42" { + t.Errorf("count: expected 42, got %s", parsed["count"]) + } + // boolean: unquoted + if string(parsed["active"]) != "true" { + t.Errorf("active: expected true, got %s", parsed["active"]) + } + // json: raw array + if string(parsed["tags"]) != `["a","b"]` { + t.Errorf("tags: expected [\"a\",\"b\"], got %s", parsed["tags"]) + } + // string: quoted + if string(parsed["name"]) != `"test"` { + t.Errorf("name: expected \"test\", got %s", parsed["name"]) + } +} + +func TestRESTBodyType_Form(t *testing.T) { + var capturedBody string + var capturedCT string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedCT = r.Header.Get("Content-Type") + body, _ := io.ReadAll(r.Body) + capturedBody = string(body) + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + + p := NewREST(map[string]RESTToolDef{ + "test": { + Method: "POST", + BaseURL: srv.URL, + Path: "/submit", + BodyType: "form", + Params: []RESTParamDef{ + {Name: "username", In: "body", Required: true}, + {Name: "password", In: "body", Required: true}, + }, + }, + }, nil) + _ = p.Setup() + + _, err := p.Execute("test", map[string]string{"username": "admin", "password": "secret"}) + if err != nil { + t.Fatal(err) + } + + if capturedCT != "application/x-www-form-urlencoded" { + t.Errorf("expected form content-type, got %q", capturedCT) + } + if !strings.Contains(capturedBody, "username=admin") { + t.Errorf("expected username=admin in body, got %q", capturedBody) + } + if !strings.Contains(capturedBody, "password=secret") { + t.Errorf("expected password=secret in body, got %q", capturedBody) + } +} + +func TestRESTBodyType_Raw(t *testing.T) { + var capturedBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + capturedBody = string(body) + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + + p := NewREST(map[string]RESTToolDef{ + "test": { + Method: "POST", + BaseURL: srv.URL, + Path: "/", + BodyType: "raw", + Params: []RESTParamDef{{Name: "payload", In: "body"}}, + }, + }, nil) + _ = p.Setup() + + _, err := p.Execute("test", map[string]string{"payload": `hello`}) + if err != nil { + t.Fatal(err) + } + if capturedBody != `hello` { + t.Errorf("expected raw XML, got %q", capturedBody) + } +} + +func TestRESTBodyType_DefaultIsJSON(t *testing.T) { + // No explicit BodyType — should default to json + var capturedBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + capturedBody = string(body) + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + + p := NewREST(map[string]RESTToolDef{ + "test": { + Method: "POST", + BaseURL: srv.URL, + Path: "/", + Params: []RESTParamDef{{Name: "key", In: "body"}}, + }, + }, nil) + _ = p.Setup() + + _, err := p.Execute("test", map[string]string{"key": "value"}) + if err != nil { + t.Fatal(err) + } + if capturedBody != `{"key":"value"}` { + t.Errorf("expected JSON object, got %q", capturedBody) + } +} diff --git a/src/internal/ui/handlers_test.go b/src/internal/ui/handlers_test.go index b8a3a39..a8b981f 100644 --- a/src/internal/ui/handlers_test.go +++ b/src/internal/ui/handlers_test.go @@ -1278,10 +1278,14 @@ func TestIntegration_VaultRefResolution(t *testing.T) { providers := make(map[string]provider.Provider) p := proxy.New(reg, providers, logger.NopLogger{}) + resolver := vault.NewResolver() + resolver.Register("vault", v) + srv, err := New(Options{ Config: cfg, CfgPath: cfgPath, Vault: v, + Resolver: resolver, Registry: reg, Proxy: p, }) @@ -1305,12 +1309,12 @@ func TestIntegration_VaultRefResolution(t *testing.T) { } found := false for _, k := range tool.VaultKeys { - if k == "API_KEY" { + if k == "vault:API_KEY" { found = true } } if !found { - t.Errorf("expected 'API_KEY' in vault keys, got %v", tool.VaultKeys) + t.Errorf("expected 'vault:API_KEY' in vault keys, got %v", tool.VaultKeys) } } diff --git a/src/internal/ui/handlers_tools.go b/src/internal/ui/handlers_tools.go index 5e43c37..de29b95 100644 --- a/src/internal/ui/handlers_tools.go +++ b/src/internal/ui/handlers_tools.go @@ -360,6 +360,7 @@ func (s *Server) handleToolSave(w http.ResponseWriter, r *http.Request) { tc.Stdin = r.FormValue("stdin") tc.Timeout = r.FormValue("timeout") tc.Body = r.FormValue("body") + tc.BodyType = r.FormValue("body_type") if mo := r.FormValue("max_output"); mo != "" { if n, err := strconv.Atoi(mo); err == nil { @@ -403,6 +404,7 @@ func (s *Server) handleToolSave(w http.ResponseWriter, r *http.Request) { params = append(params, config.ParamConfig{ Name: pname, Type: r.FormValue(fmt.Sprintf("param_type_%d", i)), + In: r.FormValue(fmt.Sprintf("param_in_%d", i)), Required: r.FormValue(fmt.Sprintf("param_required_%d", i)) == "on", Default: r.FormValue(fmt.Sprintf("param_default_%d", i)), Description: r.FormValue(fmt.Sprintf("param_desc_%d", i)), diff --git a/src/internal/ui/resolve.go b/src/internal/ui/resolve.go index 494949e..5fad7ed 100644 --- a/src/internal/ui/resolve.go +++ b/src/internal/ui/resolve.go @@ -3,65 +3,52 @@ package ui -// resolveRef resolves a single {{vault:KEY}} reference using the vault backend. +// resolveRef resolves all backend references (e.g., {{vault:KEY}}, {{op:KEY}}) +// in a string using the shared vault.Resolver. func (s *Server) resolveRef(val string) string { - v, _ := s.resolveRefTracked(val) - return v + if s.resolver == nil || val == "" { + return val + } + resolved, err := s.resolver.Resolve(val) + if err != nil { + return val + } + return resolved } -// resolveRefTracked resolves a {{vault:KEY}} reference and returns the -// vault key name if one was accessed (empty string otherwise). -func (s *Server) resolveRefTracked(val string) (string, string) { - if s.vault == nil || val == "" { - return val, "" +// resolveRefT resolves all backend refs in a value and appends accessed keys to the tracker. +func (s *Server) resolveRefT(val string, keys *[]string) string { + if s.resolver == nil || val == "" { + return val } - if len(val) > 10 && val[:8] == "{{vault:" && val[len(val)-2:] == "}}" { - key := val[8 : len(val)-2] - if resolved, err := s.vault.Get(key); err == nil { - return resolved, key - } - return val, key // still track the key even if resolution failed + resolved, accessed, err := s.resolver.ResolveTracked(val) + if err != nil { + return val } - return val, "" + *keys = append(*keys, accessed...) + return resolved } -// resolveRefsTracked resolves vault references in a string slice and collects accessed keys. +// resolveRefsTracked resolves backend references in a string slice and collects accessed keys. func (s *Server) resolveRefsTracked(vals []string, keys *[]string) []string { - if s.vault == nil || len(vals) == 0 { + if s.resolver == nil || len(vals) == 0 { return vals } out := make([]string, len(vals)) for i, v := range vals { - resolved, key := s.resolveRefTracked(v) - out[i] = resolved - if key != "" { - *keys = append(*keys, key) - } + out[i] = s.resolveRefT(v, keys) } return out } -// resolveRefMapTracked resolves vault references in a map and collects accessed keys. +// resolveRefMapTracked resolves backend references in a map and collects accessed keys. func (s *Server) resolveRefMapTracked(m map[string]string, keys *[]string) map[string]string { - if s.vault == nil || len(m) == 0 { + if s.resolver == nil || len(m) == 0 { return m } out := make(map[string]string, len(m)) for k, v := range m { - resolved, vaultKey := s.resolveRefTracked(v) - out[k] = resolved - if vaultKey != "" { - *keys = append(*keys, vaultKey) - } + out[k] = s.resolveRefT(v, keys) } return out } - -// resolveRefT resolves a ref and appends the vault key to the tracker. -func (s *Server) resolveRefT(val string, keys *[]string) string { - resolved, key := s.resolveRefTracked(val) - if key != "" { - *keys = append(*keys, key) - } - return resolved -} diff --git a/src/internal/ui/server.go b/src/internal/ui/server.go index 7710f6a..66781f8 100644 --- a/src/internal/ui/server.go +++ b/src/internal/ui/server.go @@ -9,6 +9,7 @@ import ( "io/fs" "net/http" "os" + "reflect" "strings" "time" @@ -31,6 +32,7 @@ type Server struct { registry *registry.Registry proxy *proxy.Proxy vault vault.Backend + resolver *vault.Resolver projectVault vault.Backend globalVault vault.Backend tmpls map[string]*template.Template @@ -48,6 +50,7 @@ type Options struct { Registry *registry.Registry Proxy *proxy.Proxy Vault vault.Backend + Resolver *vault.Resolver ProjectVault vault.Backend GlobalVault vault.Backend Activity *ActivityBroadcaster @@ -92,6 +95,7 @@ func New(opts Options) (*Server, error) { registry: opts.Registry, proxy: opts.Proxy, vault: opts.Vault, + resolver: opts.Resolver, projectVault: opts.ProjectVault, globalVault: opts.GlobalVault, activity: opts.Activity, @@ -168,6 +172,9 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /vault", s.handleVault) s.mux.HandleFunc("POST /vault", s.handleVaultSet) s.mux.HandleFunc("DELETE /vault/{key}", s.handleVaultDelete) + + // Reload + s.mux.HandleFunc("POST /reload", s.handleReload) } // MountMCP sets the MCP handler. The StreamableHTTPServer is its own @@ -312,11 +319,12 @@ func (s *Server) registerRESTProvider(name string, tc config.ToolConfig, vaultKe return } def := provider.RESTToolDef{ - Method: tc.Method, - BaseURL: s.resolveRefT(tc.BaseURL, vaultKeys), - Path: s.resolveRefT(tc.Path, vaultKeys), - Body: tc.Body, - Headers: s.resolveRefMapTracked(tc.Headers, vaultKeys), + Method: tc.Method, + BaseURL: s.resolveRefT(tc.BaseURL, vaultKeys), + Path: s.resolveRefT(tc.Path, vaultKeys), + Body: tc.Body, + BodyType: tc.BodyType, + Headers: s.resolveRefMapTracked(tc.Headers, vaultKeys), } if tc.Auth != nil { def.Auth = &provider.AuthDef{ @@ -444,6 +452,51 @@ func (s *Server) updateShadowRule(name string, tc config.ToolConfig) { policy.SetRule(name, rule) } +// handleReload re-reads config from disk and applies deltas to the live +// registry, providers, and shadow policy without restarting. +func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) { + newCfg, err := config.Load(s.cfgPath) + if err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `✗ %s`, template.HTMLEscapeString(err.Error())) + return + } + + var added, removed, updated int + + // Find removed tools (in old but not new) + for name := range s.cfg.Tools { + if _, exists := newCfg.Tools[name]; !exists { + s.unregisterTool(name) + removed++ + } + } + + // Find added and changed tools + for name, newTC := range newCfg.Tools { + oldTC, exists := s.cfg.Tools[name] + if !exists { + added++ + } else if !reflect.DeepEqual(oldTC, newTC) { + s.unregisterTool(name) + updated++ + } else { + continue // unchanged + } + s.registerTool(name, newTC) + } + + // Update oauth providers + s.cfg.Tools = newCfg.Tools + s.cfg.OAuthProviders = newCfg.OAuthProviders + + // Tell browser to refresh the page so sidebar/content updates + w.Header().Set("HX-Refresh", "true") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `✓ Reloaded (%d added, %d updated, %d removed)`, + added, updated, removed) +} + func templateFuncs() template.FuncMap { return template.FuncMap{ "inc": func(i int) int { return i + 1 }, diff --git a/src/internal/ui/templates/layout.html b/src/internal/ui/templates/layout.html index 97f41a9..56e5554 100644 --- a/src/internal/ui/templates/layout.html +++ b/src/internal/ui/templates/layout.html @@ -41,11 +41,18 @@ Vault History
- + +
+ + +
diff --git a/src/internal/ui/templates/tool_edit.html b/src/internal/ui/templates/tool_edit.html index 56b997e..6c83cd8 100644 --- a/src/internal/ui/templates/tool_edit.html +++ b/src/internal/ui/templates/tool_edit.html @@ -164,10 +164,20 @@

{{.Tool.Name}}

-
- - +
+
+ + +
+
+ + +
{{else if eq .Tool.Type "mcp"}} @@ -200,7 +210,7 @@

{{.Tool.Name}}

- @@ -208,6 +218,13 @@

{{.Tool.Name}}

+ @@ -423,7 +440,7 @@

Try it

- @@ -431,6 +448,13 @@

Try it

boolean + diff --git a/src/test/integration_test.go b/src/test/integration_test.go index 18d9396..ebd0bc2 100644 --- a/src/test/integration_test.go +++ b/src/test/integration_test.go @@ -622,6 +622,7 @@ tools: base_url: %s method: POST path: /items + body_type: raw parameters: - name: body in: body From 4e7db8996cf7bee742f0eca13f0274161100b14d Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Fri, 8 May 2026 12:26:53 -0400 Subject: [PATCH 41/65] Add headers configuration UI for REST tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add editable key/value header rows to the REST tool editor with dynamic add/remove, and verify param headers override static headers. UI changes: - Headers section in REST tool config with key/value input rows - "+ header" button to add new rows, ✕ to remove - Existing headers pre-populated from tool config - Save handler parses header_key[]/header_val[] arrays into Headers map - Empty keys skipped, empty map stored as nil (clean YAML) Tests: - TestHandleToolSave_WithHeaders: save with Accept + X-Custom headers - TestHandleToolSave_HeadersCleared: saving with no headers clears old ones - TestRESTParamHeaderOverridesStatic: param with in:header overrides static header of the same name --- src/internal/provider/rest_test.go | 26 +++++++++ src/internal/ui/handlers_test.go | 74 ++++++++++++++++++++++++ src/internal/ui/handlers_tools.go | 20 +++++++ src/internal/ui/templates/tool_edit.html | 28 +++++++++ 4 files changed, 148 insertions(+) diff --git a/src/internal/provider/rest_test.go b/src/internal/provider/rest_test.go index 7570352..b1e8e7a 100644 --- a/src/internal/provider/rest_test.go +++ b/src/internal/provider/rest_test.go @@ -561,6 +561,32 @@ func TestRESTContentTypeOverride(t *testing.T) { } } +func TestRESTParamHeaderOverridesStatic(t *testing.T) { + var capturedTenant string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedTenant = r.Header.Get("X-Tenant") + w.WriteHeader(200) + })) + defer srv.Close() + + p := NewREST(map[string]RESTToolDef{ + "test": { + Method: "GET", + BaseURL: srv.URL, + Path: "/", + Headers: map[string]string{"X-Tenant": "default-tenant"}, + Params: []RESTParamDef{{Name: "X-Tenant", In: "header"}}, + }, + }, nil) + _ = p.Setup() + + // Param header should override the static header + _, _ = p.Execute("test", map[string]string{"X-Tenant": "custom-tenant"}) + if capturedTenant != "custom-tenant" { + t.Errorf("expected param header to override static, got %q", capturedTenant) + } +} + // --- Response Handling --- func TestRESTSuccess200(t *testing.T) { diff --git a/src/internal/ui/handlers_test.go b/src/internal/ui/handlers_test.go index a8b981f..e24cc83 100644 --- a/src/internal/ui/handlers_test.go +++ b/src/internal/ui/handlers_test.go @@ -594,6 +594,80 @@ func TestHandleToolSave_WithShadow(t *testing.T) { } } +func TestHandleToolSave_WithHeaders(t *testing.T) { + cfg := &config.Config{ + Tools: map[string]config.ToolConfig{ + "api.test": {Type: "rest", Method: "GET", BaseURL: "https://example.com"}, + }, + } + srv, _ := testServer(t, cfg) + + form := url.Values{ + "description": {"test"}, + "method": {"GET"}, + "base_url": {"https://example.com"}, + "path": {"/test"}, + "header_key[]": {"Accept", "X-Custom"}, + "header_val[]": {"application/json", "myvalue"}, + } + + req := httptest.NewRequest("POST", "/tools/api.test", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + srv.mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + tc := srv.cfg.Tools["api.test"] + if len(tc.Headers) != 2 { + t.Fatalf("expected 2 headers, got %d: %v", len(tc.Headers), tc.Headers) + } + if tc.Headers["Accept"] != "application/json" { + t.Errorf("expected Accept header, got %v", tc.Headers) + } + if tc.Headers["X-Custom"] != "myvalue" { + t.Errorf("expected X-Custom header, got %v", tc.Headers) + } +} + +func TestHandleToolSave_HeadersCleared(t *testing.T) { + cfg := &config.Config{ + Tools: map[string]config.ToolConfig{ + "api.test": { + Type: "rest", + Method: "GET", + BaseURL: "https://example.com", + Headers: map[string]string{"Old": "value"}, + }, + }, + } + srv, _ := testServer(t, cfg) + + // Save with no headers + form := url.Values{ + "description": {"test"}, + "method": {"GET"}, + "base_url": {"https://example.com"}, + "path": {"/test"}, + } + + req := httptest.NewRequest("POST", "/tools/api.test", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + srv.mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + tc := srv.cfg.Tools["api.test"] + if tc.Headers != nil { + t.Errorf("expected nil headers after clearing, got %v", tc.Headers) + } +} + func TestHandleToolSave_NotFound(t *testing.T) { srv, _ := testServer(t, nil) diff --git a/src/internal/ui/handlers_tools.go b/src/internal/ui/handlers_tools.go index de29b95..a5c6139 100644 --- a/src/internal/ui/handlers_tools.go +++ b/src/internal/ui/handlers_tools.go @@ -385,6 +385,26 @@ func (s *Server) handleToolSave(w http.ResponseWriter, r *http.Request) { tc.BaseURL = r.FormValue("base_url") tc.Method = r.FormValue("method") tc.Path = r.FormValue("path") + // Parse headers + headerKeys := r.Form["header_key[]"] + headerVals := r.Form["header_val[]"] + headers := make(map[string]string) + for i, k := range headerKeys { + k = strings.TrimSpace(k) + if k == "" { + continue + } + v := "" + if i < len(headerVals) { + v = headerVals[i] + } + headers[k] = v + } + if len(headers) > 0 { + tc.Headers = headers + } else { + tc.Headers = nil + } case "mcp": tc.Command = r.FormValue("command") if args := r.FormValue("args"); args != "" { diff --git a/src/internal/ui/templates/tool_edit.html b/src/internal/ui/templates/tool_edit.html index 6c83cd8..d435fe0 100644 --- a/src/internal/ui/templates/tool_edit.html +++ b/src/internal/ui/templates/tool_edit.html @@ -164,6 +164,22 @@

{{.Tool.Name}}

+
+ +
+ {{range $k, $v := .Tool.Headers}} +
+ + + +
+ {{end}} +
+ +
+
@@ -414,6 +430,18 @@

Try it

+ {{end}} From bbdc60cd783fb9c76298e10456bd7e491d43ad34 Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Fri, 8 May 2026 19:44:37 -0400 Subject: [PATCH 46/65] Add value expressions for workflow params with 11 functions, proxy-level expr support Extend the expression engine from condition-only (bool) to value-returning (string) for use in step params. Add syntax for inline transformations like jsonpath extraction, timestamps, string manipulation. Also evaluate in the proxy so expressions work in any tool call, not just workflows. Expression engine (expr.go): - Add EvalExpr(): evaluates expression, returns string result (vs EvalCondition which returns bool). Reuses existing tokenizer/parser. - 11 new functions in evalCall(): - now(), now(offset): UTC RFC3339 timestamp with Go duration offset - default(var, fallback): value or fallback if empty - upper(val), lower(val), trim(val): string transforms - len(val): JSON array/object/string length - substr(val, start[, end]): substring with bounds checking - join(val, sep): join JSON array elements - replace(val, old, new): string replacement Workflow substitution (workflow.go): - substituteVars() now processes patterns first via regex, then does simple {{var}} replacement (backward compatible) - Add SubstituteExpr(): exported function for proxy use (no vars context) Proxy integration (proxy.go): - ExecuteWithContext evaluates in all param values before validation, making expressions work in any tool call (CLI, REST, MCP) - Functions like now(), upper(), default() work everywhere; jsonpath() and variable-dependent functions need workflow context Documentation: - expressions.md: new "Value expressions in step params" section with full function reference, now() offsets, len()/join() behavior tables - workflows.md: updated to reference syntax Tests (42 expr + 5 now edge + 22 edge cases + 3 workflow = 72 new): - Every function: basic + empty + edge cases - now(): 7 tests (no offset, +24h, -1h, 30m, 1h30m, invalid, 0s) - Edge cases: empty strings, out of bounds, no match, non-array join, multiple replacements, nested jsonpath, array results - Workflow integration: jsonpath extraction from stored output, now() substitution, mixed {{var}} + in same param --- docs/expressions.md | 92 ++++++- docs/workflows.md | 4 +- src/internal/provider/expr.go | 138 ++++++++++ src/internal/provider/expr_test.go | 336 +++++++++++++++++++++++++ src/internal/provider/workflow.go | 20 +- src/internal/provider/workflow_test.go | 112 +++++++++ src/internal/proxy/proxy.go | 7 + 7 files changed, 701 insertions(+), 8 deletions(-) diff --git a/docs/expressions.md b/docs/expressions.md index 619e94e..e12b5cc 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -243,14 +243,96 @@ Use `condition: "true"` as a default/else case. Each switch case supports `tool`, `params`, and `store` (same as a regular step). If no condition matches, the switch step is skipped. +## Value expressions in step params + +Use `{{expr:...}}` in step param values to transform variables before passing them to the next step. The expression is evaluated and its **string result** is substituted. + +```yaml +steps: + - tool: calendar.list_events + params: + timeMin: "{{expr:now()}}" + timeMax: "{{expr:now('24h')}}" + store: events + + - tool: slack.post + params: + message: "Next event: {{expr:jsonpath(events, '$.items[0].summary')}}" + count: "{{expr:len(events)}}" +``` + +The `{{expr:...}}` prefix distinguishes expressions from simple variable substitution (`{{variable}}`). Both can be mixed in the same string: + +```yaml +params: + message: "Hello {{name}}, you have {{expr:len(items)}} items" +``` + +### Value functions + +| Function | Description | Example | +|----------|-------------|---------| +| `now()` | Current UTC timestamp (RFC3339) | `now()` → `2026-05-08T12:00:00Z` | +| `now(offset)` | Timestamp with offset | `now('24h')`, `now('-1h')`, `now('30m')` | +| `jsonpath(var, path)` | Extract from JSON | `jsonpath(data, '$.items[0].name')` | +| `default(var, fallback)` | Value or fallback if empty | `default(title, 'Untitled')` | +| `upper(val)` | Uppercase | `upper(name)` → `JORDAN` | +| `lower(val)` | Lowercase | `lower(name)` → `jordan` | +| `trim(val)` | Strip whitespace | `trim(output)` | +| `len(val)` | Length (array, object, or string) | `len(items)` → `3` | +| `substr(val, start)` | Substring from position | `substr(id, 0, 8)` | +| `substr(val, start, end)` | Substring with end | `substr(hash, 0, 7)` | +| `join(val, sep)` | Join JSON array | `join(tags, ', ')` → `go, rust` | +| `replace(val, old, new)` | String replacement | `replace(output, '\n', ' ')` | + +These functions also work in `if:`/`require:` conditions and `switch:` cases — they return values that can be compared: + +```yaml +if: "len(items) > 0" +if: "jsonpath(data, '$.status') == 'ok'" +if: "lower(env) == 'prod'" +``` + +### `now()` offsets + +The offset uses Go duration syntax: `h` (hours), `m` (minutes), `s` (seconds). Prefix with `-` for past. + +| Offset | Result | +|--------|--------| +| `now()` | Current time | +| `now('24h')` | 24 hours from now | +| `now('-1h')` | 1 hour ago | +| `now('30m')` | 30 minutes from now | +| `now('1h30m')` | 90 minutes from now | + +### `len()` behavior + +| Input | Result | +|-------|--------| +| JSON array `["a","b"]` | `2` | +| JSON object `{"a":1}` | `1` | +| Plain string `"hello"` | `5` | +| Empty `""` | `0` | + +### `join()` behavior + +Parses the value as a JSON array and joins elements with the separator. Non-array values pass through unchanged. + +```yaml +# tags = ["go", "rust", "python"] +join(tags, ', ') # → "go, rust, python" +join(tags, ' | ') # → "go | rust | python" +``` + ## Error handling -Expressions that fail to parse or evaluate return false (fail-closed). This means: +Expressions that fail to parse or evaluate return false (fail-closed) in conditions, or empty string in value expressions. This means: -- Typos in variable names → falsy (step skipped) -- Malformed expressions → falsy -- Invalid JSON in jsonpath → falsy -- Division by zero, overflow → falsy +- Typos in variable names → falsy / empty +- Malformed expressions → falsy / empty +- Invalid JSON in jsonpath → falsy / empty +- Division by zero, overflow → falsy / empty +- Invalid `now()` offset → current time (no offset applied) No panics, no workflow crashes from bad expressions. diff --git a/docs/workflows.md b/docs/workflows.md index 23fc4ba..4ae7b3f 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -35,7 +35,7 @@ factorly call daily.summary 1. Workflow receives input parameters from the caller 2. Steps execute sequentially, each through the full proxy pipeline (parameter validation → shadow policy → execute → output filter → log) 3. `store:` saves step output as a named variable for later steps -4. `{{var}}` in step params is substituted from stored outputs + input params +4. `{{var}}` in step params is substituted from stored outputs + input params. Use `{{expr:func(...)}}` for [value expressions](expressions.md#value-expressions-in-step-params). 5. On failure, remaining steps are skipped 6. State is persisted to `.factorly/workflows/.json` after each step 7. Output includes the full execution trace as JSON @@ -47,7 +47,7 @@ factorly call daily.summary | Field | Required | Description | |-------|----------|-------------| | `tool` | Yes* | Tool name to call (must exist in config) | -| `params` | No | Parameters to pass — supports `{{var}}` substitution | +| `params` | No | Parameters to pass — supports `{{var}}` and `{{expr:...}}` substitution | | `store` | No | Save step output as a named variable | | `if` | No | [Expression](expressions.md) — skip step when false, continue workflow | | `require` | No | [Expression](expressions.md) — stop workflow when false | diff --git a/src/internal/provider/expr.go b/src/internal/provider/expr.go index 43e890a..4bb7b53 100644 --- a/src/internal/provider/expr.go +++ b/src/internal/provider/expr.go @@ -5,14 +5,35 @@ package provider import ( "encoding/json" + "fmt" "strconv" "strings" + "time" "unicode" "github.com/ohler55/ojg/jp" "github.com/ohler55/ojg/oj" ) +// EvalExpr evaluates an expression and returns its string result. +// Used for {{expr:...}} substitution in workflow step params. +func EvalExpr(expr string, vars map[string]string) string { + expr = strings.TrimSpace(expr) + if expr == "" { + return "" + } + tokens := tokenize(expr) + node := parse(tokens) + if node == nil { + return "" + } + val, err := evaluate(node, vars) + if err != nil { + return "" + } + return toString(val) +} + // EvalCondition evaluates a condition expression against workflow variables. // Returns true if the expression is truthy. Errors are treated as false (fail-closed). func EvalCondition(expr string, vars map[string]string) bool { @@ -410,10 +431,127 @@ func evalCall(name string, args []node, vars map[string]string) any { expr, _ := evaluate(args[1], vars) return evalJSONPath(toString(data), toString(expr)) } + case "now": + return evalNow(args, vars) + case "default": + if len(args) >= 2 { + val, _ := evaluate(args[0], vars) + s := toString(val) + if s != "" { + return s + } + fallback, _ := evaluate(args[1], vars) + return toString(fallback) + } + case "upper": + if len(args) >= 1 { + val, _ := evaluate(args[0], vars) + return strings.ToUpper(toString(val)) + } + case "lower": + if len(args) >= 1 { + val, _ := evaluate(args[0], vars) + return strings.ToLower(toString(val)) + } + case "trim": + if len(args) >= 1 { + val, _ := evaluate(args[0], vars) + return strings.TrimSpace(toString(val)) + } + case "len": + if len(args) >= 1 { + val, _ := evaluate(args[0], vars) + return evalLen(toString(val)) + } + case "substr": + return evalSubstr(args, vars) + case "join": + if len(args) >= 2 { + val, _ := evaluate(args[0], vars) + sep, _ := evaluate(args[1], vars) + return evalJoin(toString(val), toString(sep)) + } + case "replace": + if len(args) >= 3 { + val, _ := evaluate(args[0], vars) + old, _ := evaluate(args[1], vars) + new, _ := evaluate(args[2], vars) + return strings.ReplaceAll(toString(val), toString(old), toString(new)) + } } return false } +func evalNow(args []node, vars map[string]string) string { + t := time.Now().UTC() + if len(args) >= 1 { + offset, _ := evaluate(args[0], vars) + if d, err := time.ParseDuration(toString(offset)); err == nil { + t = t.Add(d) + } + } + return t.Format(time.RFC3339) +} + +func evalLen(s string) int { + // Try as JSON array + var arr []any + if json.Unmarshal([]byte(s), &arr) == nil { + return len(arr) + } + // Try as JSON object + var obj map[string]any + if json.Unmarshal([]byte(s), &obj) == nil { + return len(obj) + } + return len(s) +} + +func evalSubstr(args []node, vars map[string]string) string { + if len(args) < 2 { + return "" + } + val, _ := evaluate(args[0], vars) + s := toString(val) + startVal, _ := evaluate(args[1], vars) + start, _ := strconv.Atoi(fmt.Sprint(startVal)) + if start < 0 { + start = 0 + } + if start >= len(s) { + return "" + } + end := len(s) + if len(args) >= 3 { + endVal, _ := evaluate(args[2], vars) + if e, err := strconv.Atoi(fmt.Sprint(endVal)); err == nil && e < end { + end = e + } + } + if end > len(s) { + end = len(s) + } + return s[start:end] +} + +func evalJoin(s, sep string) string { + var arr []any + if json.Unmarshal([]byte(s), &arr) != nil { + return s + } + parts := make([]string, len(arr)) + for i, v := range arr { + switch val := v.(type) { + case string: + parts[i] = val + default: + b, _ := json.Marshal(val) + parts[i] = string(b) + } + } + return strings.Join(parts, sep) +} + func evalJSONPath(data, path string) any { parsed, err := jp.ParseString(path) if err != nil { diff --git a/src/internal/provider/expr_test.go b/src/internal/provider/expr_test.go index 95d695c..224a507 100644 --- a/src/internal/provider/expr_test.go +++ b/src/internal/provider/expr_test.go @@ -5,7 +5,9 @@ package provider import ( "fmt" + "strings" "testing" + "time" ) func TestExprStringEquality(t *testing.T) { @@ -721,3 +723,337 @@ func TestExprRealWorldConditions(t *testing.T) { t.Error("missing approval should block deploy") } } + +// --- EvalExpr tests --- + +func TestEvalExprNow(t *testing.T) { + result := EvalExpr("now()", nil) + if result == "" { + t.Fatal("now() should return a timestamp") + } + if _, err := time.Parse(time.RFC3339, result); err != nil { + t.Errorf("now() should return RFC3339, got %q: %v", result, err) + } +} + +func TestEvalExprNowWithOffset(t *testing.T) { + result := EvalExpr("now('24h')", nil) + if result == "" { + t.Fatal("now('24h') should return a timestamp") + } + ts, err := time.Parse(time.RFC3339, result) + if err != nil { + t.Fatalf("parse error: %v", err) + } + diff := ts.Sub(time.Now().UTC()) + if diff < 23*time.Hour || diff > 25*time.Hour { + t.Errorf("expected ~24h in future, got %v", diff) + } +} + +func TestEvalExprNowNegativeOffset(t *testing.T) { + result := EvalExpr("now('-1h')", nil) + ts, _ := time.Parse(time.RFC3339, result) + diff := time.Now().UTC().Sub(ts) + if diff < 50*time.Minute || diff > 70*time.Minute { + t.Errorf("expected ~1h in past, got %v", diff) + } +} + +func TestEvalExprNowMinutes(t *testing.T) { + result := EvalExpr("now('30m')", nil) + ts, err := time.Parse(time.RFC3339, result) + if err != nil { + t.Fatalf("parse error: %v", err) + } + diff := ts.Sub(time.Now().UTC()) + if diff < 25*time.Minute || diff > 35*time.Minute { + t.Errorf("expected ~30m in future, got %v", diff) + } +} + +func TestEvalExprNowCombined(t *testing.T) { + result := EvalExpr("now('1h30m')", nil) + ts, err := time.Parse(time.RFC3339, result) + if err != nil { + t.Fatalf("parse error: %v", err) + } + diff := ts.Sub(time.Now().UTC()) + if diff < 85*time.Minute || diff > 95*time.Minute { + t.Errorf("expected ~90m in future, got %v", diff) + } +} + +func TestEvalExprNowInvalidOffset(t *testing.T) { + // Invalid offset should fall back to current time (no offset applied) + result := EvalExpr("now('garbage')", nil) + ts, err := time.Parse(time.RFC3339, result) + if err != nil { + t.Fatalf("parse error: %v", err) + } + diff := time.Since(ts) + if diff < -5*time.Second || diff > 5*time.Second { + t.Errorf("invalid offset should return ~now, got %v diff", diff) + } +} + +func TestEvalExprNowZeroOffset(t *testing.T) { + result := EvalExpr("now('0s')", nil) + ts, err := time.Parse(time.RFC3339, result) + if err != nil { + t.Fatalf("parse error: %v", err) + } + diff := time.Since(ts) + if diff < -5*time.Second || diff > 5*time.Second { + t.Errorf("zero offset should return ~now, got %v diff", diff) + } +} + +func TestEvalExprDefault(t *testing.T) { + vars := map[string]string{"title": "Hello"} + if r := EvalExpr("default(title, 'Untitled')", vars); r != "Hello" { + t.Errorf("expected 'Hello', got %q", r) + } + vars["title"] = "" + if r := EvalExpr("default(title, 'Untitled')", vars); r != "Untitled" { + t.Errorf("expected 'Untitled', got %q", r) + } +} + +func TestEvalExprDefaultMissing(t *testing.T) { + if r := EvalExpr("default(missing, 'fallback')", map[string]string{}); r != "fallback" { + t.Errorf("expected 'fallback', got %q", r) + } +} + +func TestEvalExprUpper(t *testing.T) { + vars := map[string]string{"name": "jordan"} + if r := EvalExpr("upper(name)", vars); r != "JORDAN" { + t.Errorf("expected 'JORDAN', got %q", r) + } +} + +func TestEvalExprLower(t *testing.T) { + vars := map[string]string{"name": "JORDAN"} + if r := EvalExpr("lower(name)", vars); r != "jordan" { + t.Errorf("expected 'jordan', got %q", r) + } +} + +func TestEvalExprTrim(t *testing.T) { + vars := map[string]string{"output": " hello world \n"} + if r := EvalExpr("trim(output)", vars); r != "hello world" { + t.Errorf("expected 'hello world', got %q", r) + } +} + +func TestEvalExprLen(t *testing.T) { + vars := map[string]string{"items": `["a","b","c"]`} + if r := EvalExpr("len(items)", vars); r != "3" { + t.Errorf("expected '3', got %q", r) + } +} + +func TestEvalExprLenString(t *testing.T) { + vars := map[string]string{"name": "hello"} + if r := EvalExpr("len(name)", vars); r != "5" { + t.Errorf("expected '5', got %q", r) + } +} + +func TestEvalExprLenObject(t *testing.T) { + vars := map[string]string{"data": `{"a":1,"b":2}`} + if r := EvalExpr("len(data)", vars); r != "2" { + t.Errorf("expected '2', got %q", r) + } +} + +func TestEvalExprSubstr(t *testing.T) { + vars := map[string]string{"id": "abcdefghij"} + if r := EvalExpr("substr(id, 0, 4)", vars); r != "abcd" { + t.Errorf("expected 'abcd', got %q", r) + } +} + +func TestEvalExprSubstrNoEnd(t *testing.T) { + vars := map[string]string{"id": "abcdefghij"} + if r := EvalExpr("substr(id, 5)", vars); r != "fghij" { + t.Errorf("expected 'fghij', got %q", r) + } +} + +func TestEvalExprJoin(t *testing.T) { + vars := map[string]string{"tags": `["go","rust","python"]`} + if r := EvalExpr("join(tags, ', ')", vars); r != "go, rust, python" { + t.Errorf("expected 'go, rust, python', got %q", r) + } +} + +func TestEvalExprReplace(t *testing.T) { + vars := map[string]string{"output": "hello\nworld\n"} + if r := EvalExpr("replace(output, '\n', ' ')", vars); !strings.Contains(r, "hello") { + t.Errorf("expected replaced output, got %q", r) + } +} + +func TestEvalExprJSONPath(t *testing.T) { + vars := map[string]string{"data": `{"items":[{"name":"first"},{"name":"second"}]}`} + if r := EvalExpr("jsonpath(data, '$.items[0].name')", vars); r != "first" { + t.Errorf("expected 'first', got %q", r) + } +} + +func TestEvalExprEmpty(t *testing.T) { + if r := EvalExpr("", nil); r != "" { + t.Errorf("expected empty, got %q", r) + } +} + +func TestEvalExprLiteral(t *testing.T) { + if r := EvalExpr("'hello'", nil); r != "hello" { + t.Errorf("expected 'hello', got %q", r) + } +} + +// --- Edge case tests for expression functions --- + +func TestEvalExprUpperEmpty(t *testing.T) { + vars := map[string]string{"x": ""} + if r := EvalExpr("upper(x)", vars); r != "" { + t.Errorf("expected empty, got %q", r) + } +} + +func TestEvalExprUpperAlready(t *testing.T) { + vars := map[string]string{"x": "ABC"} + if r := EvalExpr("upper(x)", vars); r != "ABC" { + t.Errorf("expected 'ABC', got %q", r) + } +} + +func TestEvalExprLowerEmpty(t *testing.T) { + vars := map[string]string{"x": ""} + if r := EvalExpr("lower(x)", vars); r != "" { + t.Errorf("expected empty, got %q", r) + } +} + +func TestEvalExprTrimEmpty(t *testing.T) { + vars := map[string]string{"x": ""} + if r := EvalExpr("trim(x)", vars); r != "" { + t.Errorf("expected empty, got %q", r) + } +} + +func TestEvalExprTrimNoWhitespace(t *testing.T) { + vars := map[string]string{"x": "clean"} + if r := EvalExpr("trim(x)", vars); r != "clean" { + t.Errorf("expected 'clean', got %q", r) + } +} + +func TestEvalExprLenEmptyArray(t *testing.T) { + vars := map[string]string{"items": `[]`} + if r := EvalExpr("len(items)", vars); r != "0" { + t.Errorf("expected '0', got %q", r) + } +} + +func TestEvalExprLenEmptyString(t *testing.T) { + vars := map[string]string{"x": ""} + if r := EvalExpr("len(x)", vars); r != "0" { + t.Errorf("expected '0', got %q", r) + } +} + +func TestEvalExprSubstrOutOfBounds(t *testing.T) { + vars := map[string]string{"x": "abc"} + if r := EvalExpr("substr(x, 10)", vars); r != "" { + t.Errorf("expected empty for out of bounds start, got %q", r) + } +} + +func TestEvalExprSubstrEndBeyondLength(t *testing.T) { + vars := map[string]string{"x": "abc"} + if r := EvalExpr("substr(x, 0, 100)", vars); r != "abc" { + t.Errorf("expected 'abc' when end > len, got %q", r) + } +} + +func TestEvalExprSubstrEmpty(t *testing.T) { + vars := map[string]string{"x": ""} + if r := EvalExpr("substr(x, 0, 5)", vars); r != "" { + t.Errorf("expected empty, got %q", r) + } +} + +func TestEvalExprJoinEmptyArray(t *testing.T) { + vars := map[string]string{"items": `[]`} + if r := EvalExpr("join(items, ', ')", vars); r != "" { + t.Errorf("expected empty, got %q", r) + } +} + +func TestEvalExprJoinSingleItem(t *testing.T) { + vars := map[string]string{"items": `["only"]`} + if r := EvalExpr("join(items, ', ')", vars); r != "only" { + t.Errorf("expected 'only', got %q", r) + } +} + +func TestEvalExprJoinNonArray(t *testing.T) { + vars := map[string]string{"x": "not json"} + if r := EvalExpr("join(x, ', ')", vars); r != "not json" { + t.Errorf("expected passthrough for non-array, got %q", r) + } +} + +func TestEvalExprReplaceNoMatch(t *testing.T) { + vars := map[string]string{"x": "hello"} + if r := EvalExpr("replace(x, 'z', 'a')", vars); r != "hello" { + t.Errorf("expected 'hello' unchanged, got %q", r) + } +} + +func TestEvalExprReplaceMultiple(t *testing.T) { + vars := map[string]string{"x": "aaa"} + if r := EvalExpr("replace(x, 'a', 'b')", vars); r != "bbb" { + t.Errorf("expected 'bbb', got %q", r) + } +} + +func TestEvalExprReplaceEmpty(t *testing.T) { + vars := map[string]string{"x": ""} + if r := EvalExpr("replace(x, 'a', 'b')", vars); r != "" { + t.Errorf("expected empty, got %q", r) + } +} + +func TestEvalExprJSONPathNoMatch(t *testing.T) { + vars := map[string]string{"data": `{"a":1}`} + if r := EvalExpr("jsonpath(data, '$.nonexistent')", vars); r != "" { + t.Errorf("expected empty for no match, got %q", r) + } +} + +func TestEvalExprJSONPathNested(t *testing.T) { + vars := map[string]string{"data": `{"a":{"b":{"c":"deep"}}}`} + if r := EvalExpr("jsonpath(data, '$.a.b.c')", vars); r != "deep" { + t.Errorf("expected 'deep', got %q", r) + } +} + +func TestEvalExprJSONPathArrayResult(t *testing.T) { + vars := map[string]string{"data": `{"items":[{"id":1},{"id":2}]}`} + r := EvalExpr("jsonpath(data, '$.items[*].id')", vars) + if !strings.Contains(r, "1") || !strings.Contains(r, "2") { + t.Errorf("expected array with 1 and 2, got %q", r) + } +} + +func TestEvalExprDefaultBothEmpty(t *testing.T) { + vars := map[string]string{"x": ""} + if r := EvalExpr("default(x, '')", vars); r != "" { + t.Errorf("expected empty, got %q", r) + } +} diff --git a/src/internal/provider/workflow.go b/src/internal/provider/workflow.go index 6265d71..706c436 100644 --- a/src/internal/provider/workflow.go +++ b/src/internal/provider/workflow.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "time" @@ -321,8 +322,25 @@ func (p *WorkflowProvider) ExecuteWithContext(ctx context.Context, toolName stri }, nil } +var exprPattern = regexp.MustCompile(`\{\{expr:(.+?)\}\}`) + +// SubstituteExpr evaluates {{expr:...}} patterns in a string without +// variable context. Used by the proxy to resolve expressions in param +// values for any tool call (not just workflows). +func SubstituteExpr(s string) string { + return exprPattern.ReplaceAllStringFunc(s, func(match string) string { + expr := match[7 : len(match)-2] + return EvalExpr(expr, nil) + }) +} + func substituteVars(tmpl string, vars map[string]string) string { - result := tmpl + // First: evaluate {{expr:...}} patterns + result := exprPattern.ReplaceAllStringFunc(tmpl, func(match string) string { + expr := match[7 : len(match)-2] // strip {{expr: and }} + return EvalExpr(expr, vars) + }) + // Then: simple {{var}} substitution (backward compatible) for k, v := range vars { result = strings.ReplaceAll(result, "{{"+k+"}}", v) } diff --git a/src/internal/provider/workflow_test.go b/src/internal/provider/workflow_test.go index c43fa59..ffa6d7e 100644 --- a/src/internal/provider/workflow_test.go +++ b/src/internal/provider/workflow_test.go @@ -803,3 +803,115 @@ func TestWorkflowRequireWithParams(t *testing.T) { t.Error("expected skipped") } } + +func TestWorkflowExprSubstitution(t *testing.T) { + var capturedParams map[string]string + exec := &mockWorkflowExecutor{ + results: map[string]*Result{ + "fetch": {Output: `{"items":[{"name":"first"}]}`}, + }, + } + // Override to capture params + origExec := exec + wp := NewWorkflowProvider(¶mCapturingExecutor{ + executor: origExec, + capturedParams: &capturedParams, + captureFor: "process", + }, false) + wp.RegisterWorkflow("test", []WorkflowStep{ + {Tool: "fetch", Store: "data"}, + {Tool: "process", Params: map[string]string{ + "title": `{{expr:jsonpath(data, '$.items[0].name')}}`, + "upper": `{{expr:upper(input)}}`, + }}, + }) + _ = wp.Setup() + + result, err := wp.Execute("test", map[string]string{"input": "hello"}) + if err != nil { + t.Fatal(err) + } + if result.IsError() { + t.Fatal(result.Error) + } + if capturedParams["title"] != "first" { + t.Errorf("expected jsonpath result 'first', got %q", capturedParams["title"]) + } + if capturedParams["upper"] != "HELLO" { + t.Errorf("expected upper 'HELLO', got %q", capturedParams["upper"]) + } +} + +// paramCapturingExecutor wraps a mock executor and captures params for a specific tool. +type paramCapturingExecutor struct { + executor *mockWorkflowExecutor + capturedParams *map[string]string + captureFor string +} + +func (p *paramCapturingExecutor) ExecuteWithContext(ctx context.Context, toolName string, params map[string]string, iface string) (*Result, error) { + if toolName == p.captureFor { + captured := make(map[string]string) + for k, v := range params { + captured[k] = v + } + *p.capturedParams = captured + } + return p.executor.ExecuteWithContext(ctx, toolName, params, iface) +} + +func TestWorkflowExprNow(t *testing.T) { + var capturedParams map[string]string + exec := &mockWorkflowExecutor{} + wp := NewWorkflowProvider(¶mCapturingExecutor{ + executor: exec, + capturedParams: &capturedParams, + captureFor: "api_call", + }, false) + wp.RegisterWorkflow("test", []WorkflowStep{ + {Tool: "api_call", Params: map[string]string{ + "timestamp": `{{expr:now()}}`, + }}, + }) + _ = wp.Setup() + + _, err := wp.Execute("test", nil) + if err != nil { + t.Fatal(err) + } + if capturedParams["timestamp"] == "" || capturedParams["timestamp"] == "{{expr:now()}}" { + t.Errorf("expected resolved timestamp, got %q", capturedParams["timestamp"]) + } +} + +func TestWorkflowExprMixedWithVars(t *testing.T) { + var capturedParams map[string]string + exec := &mockWorkflowExecutor{ + results: map[string]*Result{ + "step1": {Output: "world"}, + }, + } + wp := NewWorkflowProvider(¶mCapturingExecutor{ + executor: exec, + capturedParams: &capturedParams, + captureFor: "step2", + }, false) + wp.RegisterWorkflow("test", []WorkflowStep{ + {Tool: "step1", Store: "greeting"}, + {Tool: "step2", Params: map[string]string{ + "msg": `hello {{greeting}} {{expr:upper(greeting)}}`, + }}, + }) + _ = wp.Setup() + + result, err := wp.Execute("test", nil) + if err != nil { + t.Fatal(err) + } + if result.IsError() { + t.Fatal(result.Error) + } + if capturedParams["msg"] != "hello world WORLD" { + t.Errorf("expected 'hello world WORLD', got %q", capturedParams["msg"]) + } +} diff --git a/src/internal/proxy/proxy.go b/src/internal/proxy/proxy.go index 20abbcc..cafb4e2 100644 --- a/src/internal/proxy/proxy.go +++ b/src/internal/proxy/proxy.go @@ -115,6 +115,13 @@ func (p *Proxy) ExecuteWithContext(ctx context.Context, toolName string, params } } + // Evaluate {{expr:...}} in param values (e.g., {{expr:now()}}) + for k, v := range params { + if strings.Contains(v, "{{expr:") { + params[k] = provider.SubstituteExpr(v) + } + } + agentID := agent.AgentID(ctx) // Parameter validation and coercion From 8bda5bc706f0dc7527d62bdfdf07672cb2504c1f Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Fri, 8 May 2026 22:58:54 -0400 Subject: [PATCH 47/65] Unify {{expr:...}} as a resolver backend alongside {{vault:...}} and {{op:...}} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register expr as a ResolveFunc on the vault.Resolver so all {{backend:content}} patterns are dispatched through a single system. The proxy resolves all patterns in one pass at call time instead of separate vault + expr handling. Resolver changes (vault/resolver.go): - Widen refPattern regex: key group from [A-Za-z0-9_./-]+ to .+? (non-greedy) so expressions with parens/commas/quotes match - Add ResolveFunc type: func(content string) (string, error) — lightweight read-only resolver for computed backends that don't need Get/Set/Delete - Add RegisterFunc(prefix, fn): registers alongside full backends - Resolve() and ResolveTracked() check funcs first, then backends - Expr funcs are NOT tracked in ResolveTracked (they're not secrets) Proxy changes (proxy/proxy.go): - Add resolver field and WithResolver option - Replace manual SubstituteExpr call with resolver.Resolve() on all params containing {{ — one pass handles both vault and expr - Remove provider.SubstituteExpr dependency Bootstrap (main.go): - Register "expr" as ResolveFunc: EvalExpr(content, nil) - Pass resolver to proxy via WithResolver Cleanup: - Remove SubstituteExpr from workflow.go (dead code) - Workflow substituteVars still handles {{expr:...}} with vars context independently for workflow-scoped variable access Tests (7 new resolver tests): - TestResolveFuncExpr: basic expr resolution - TestResolveFuncWithVaultBackend: mixed vault+expr in one string - TestResolveFuncOverridesBackend: func takes priority over backend - TestResolveFuncNotTracked: expr funcs excluded from accessed keys - TestResolveFuncError: error leaves pattern unchanged - TestResolveFuncWithDefault: fallback on expr error - TestResolveFuncComplexExpr: full expression with parens/quotes passed --- src/cmd/factorly/main.go | 11 +++ src/internal/provider/workflow.go | 10 --- src/internal/proxy/proxy.go | 20 ++++- src/internal/vault/resolver.go | 49 ++++++++++++- src/internal/vault/resolver_test.go | 109 ++++++++++++++++++++++++++++ 5 files changed, 182 insertions(+), 17 deletions(-) diff --git a/src/cmd/factorly/main.go b/src/cmd/factorly/main.go index 57ce902..9b2c138 100644 --- a/src/cmd/factorly/main.go +++ b/src/cmd/factorly/main.go @@ -586,6 +586,13 @@ func bootstrapProviders(cfg *config.Config, reg *registry.Registry, confirmFn .. } cachedResolver = resolver + // Register expr as a resolver backend so {{expr:now()}} etc. work everywhere + if resolver != nil { + resolver.RegisterFunc("expr", func(content string) (string, error) { + return provider.EvalExpr(content, nil), nil + }) + } + providers := make(map[string]provider.Provider) cliTools := make(map[string]provider.CLIToolDef) restTools := make(map[string]provider.RESTToolDef) @@ -843,6 +850,10 @@ func bootstrapProviders(cfg *config.Config, reg *registry.Registry, confirmFn .. vlog("shadow policy active (%d rules)", len(shadowRules)) } + if resolver != nil { + proxyOpts = append(proxyOpts, proxy.WithResolver(resolver)) + } + p := proxy.New(reg, providers, logIface, proxyOpts...) // Wire workflow provider after proxy creation (needs proxy reference for step execution) diff --git a/src/internal/provider/workflow.go b/src/internal/provider/workflow.go index 706c436..bd5324b 100644 --- a/src/internal/provider/workflow.go +++ b/src/internal/provider/workflow.go @@ -324,16 +324,6 @@ func (p *WorkflowProvider) ExecuteWithContext(ctx context.Context, toolName stri var exprPattern = regexp.MustCompile(`\{\{expr:(.+?)\}\}`) -// SubstituteExpr evaluates {{expr:...}} patterns in a string without -// variable context. Used by the proxy to resolve expressions in param -// values for any tool call (not just workflows). -func SubstituteExpr(s string) string { - return exprPattern.ReplaceAllStringFunc(s, func(match string) string { - expr := match[7 : len(match)-2] - return EvalExpr(expr, nil) - }) -} - func substituteVars(tmpl string, vars map[string]string) string { // First: evaluate {{expr:...}} patterns result := exprPattern.ReplaceAllStringFunc(tmpl, func(match string) string { diff --git a/src/internal/proxy/proxy.go b/src/internal/proxy/proxy.go index cafb4e2..53920bf 100644 --- a/src/internal/proxy/proxy.go +++ b/src/internal/proxy/proxy.go @@ -18,6 +18,7 @@ import ( "github.com/factorly-dev/factorly/internal/provider" "github.com/factorly-dev/factorly/internal/registry" "github.com/factorly-dev/factorly/internal/shadow" + "github.com/factorly-dev/factorly/internal/vault" ) // Option configures a Proxy. @@ -33,6 +34,12 @@ func WithOnCall(fn func(CallEvent)) Option { return func(p *Proxy) { p.onCall = fn } } +// WithResolver sets a vault resolver for resolving {{backend:content}} +// patterns in param values at call time. +func WithResolver(r *vault.Resolver) Option { + return func(p *Proxy) { p.resolver = r } +} + // SetOnCall sets the call event callback after proxy creation. func (p *Proxy) SetOnCall(fn func(CallEvent)) { p.onCall = fn @@ -57,6 +64,7 @@ type Proxy struct { logger logger.Logger shadow *shadow.Policy onCall func(CallEvent) + resolver *vault.Resolver } func New(reg *registry.Registry, providers map[string]provider.Provider, log logger.Logger, opts ...Option) *Proxy { @@ -115,10 +123,14 @@ func (p *Proxy) ExecuteWithContext(ctx context.Context, toolName string, params } } - // Evaluate {{expr:...}} in param values (e.g., {{expr:now()}}) - for k, v := range params { - if strings.Contains(v, "{{expr:") { - params[k] = provider.SubstituteExpr(v) + // Resolve {{backend:content}} patterns in param values (e.g., {{expr:now()}}) + if p.resolver != nil { + for k, v := range params { + if strings.Contains(v, "{{") { + if resolved, err := p.resolver.Resolve(v); err == nil { + params[k] = resolved + } + } } } diff --git a/src/internal/vault/resolver.go b/src/internal/vault/resolver.go index 053f440..0702898 100644 --- a/src/internal/vault/resolver.go +++ b/src/internal/vault/resolver.go @@ -9,21 +9,38 @@ import ( "strings" ) -var refPattern = regexp.MustCompile(`\{\{([A-Za-z0-9_][A-Za-z0-9_-]*):([A-Za-z0-9_./-]+)(?:\|([^}]*))?\}\}`) +// refPattern matches {{backend:content}} and {{backend:content|default}}. +// The content group uses .+? (non-greedy) to support both simple keys (MY_SECRET) +// and complex expressions (now('24h'), jsonpath(data, '$.field')). +var refPattern = regexp.MustCompile(`\{\{([A-Za-z0-9_][A-Za-z0-9_-]*):(.+?)(?:\|([^}]*))?\}\}`) -// Resolver resolves {{backend:key}} references by dispatching to registered backends. +// ResolveFunc is a lightweight read-only resolver for computed backends +// like expr that don't need Get/Set/Delete/List/Close. +type ResolveFunc func(content string) (string, error) + +// Resolver resolves {{backend:content}} references by dispatching to registered backends. type Resolver struct { backends map[string]Backend + funcs map[string]ResolveFunc } func NewResolver() *Resolver { - return &Resolver{backends: make(map[string]Backend)} + return &Resolver{ + backends: make(map[string]Backend), + funcs: make(map[string]ResolveFunc), + } } func (r *Resolver) Register(prefix string, b Backend) { r.backends[prefix] = b } +// RegisterFunc registers a lightweight resolver function for a prefix. +// Used for computed backends like "expr" that only resolve, never store. +func (r *Resolver) RegisterFunc(prefix string, fn ResolveFunc) { + r.funcs[prefix] = fn +} + // Backend returns a registered backend by name, or nil if not found. func (r *Resolver) Backend(name string) Backend { return r.backends[name] @@ -50,6 +67,19 @@ func (r *Resolver) Resolve(s string) (string, error) { if hasDefault { defaultVal = parts[3] } + // Try ResolveFunc first (lightweight computed backends like expr) + if fn, ok := r.funcs[backend]; ok { + val, err := fn(key) + if err != nil { + if hasDefault { + return defaultVal + } + return match + } + return val + } + + // Then try full Backend b, ok := r.backends[backend] if !ok { if hasDefault { @@ -91,6 +121,19 @@ func (r *Resolver) ResolveTracked(s string) (string, []string, error) { if hasDefault { defaultVal = parts[3] } + // Try ResolveFunc first + if fn, ok := r.funcs[backend]; ok { + val, err := fn(key) + if err != nil { + if hasDefault { + return defaultVal + } + return match + } + // Don't track expr funcs as accessed keys (they're not secrets) + return val + } + b, ok := r.backends[backend] if !ok { if hasDefault { diff --git a/src/internal/vault/resolver_test.go b/src/internal/vault/resolver_test.go index 5536ed4..cffa4b7 100644 --- a/src/internal/vault/resolver_test.go +++ b/src/internal/vault/resolver_test.go @@ -4,6 +4,7 @@ package vault import ( + "fmt" "strings" "testing" ) @@ -362,3 +363,111 @@ func (m *mapBackend) List() ([]string, error) { } func (m *mapBackend) Close() error { return nil } + +// --- ResolveFunc tests --- + +func TestResolveFuncExpr(t *testing.T) { + r := NewResolver() + r.RegisterFunc("expr", func(content string) (string, error) { + // Simulate a simple expression evaluator + if content == "now()" { + return "2026-05-09T00:00:00Z", nil + } + return content, nil + }) + + result, err := r.Resolve("time is {{expr:now()}}") + if err != nil { + t.Fatal(err) + } + if result != "time is 2026-05-09T00:00:00Z" { + t.Errorf("expected resolved expr, got %q", result) + } +} + +func TestResolveFuncWithVaultBackend(t *testing.T) { + r := NewResolver() + r.Register("vault", &mockBackend{secrets: map[string]string{"TOKEN": "secret123"}}) + r.RegisterFunc("expr", func(content string) (string, error) { + return "evaluated:" + content, nil + }) + + // Both should resolve in one pass + result, err := r.Resolve("key={{vault:TOKEN}} expr={{expr:hello}}") + if err != nil { + t.Fatal(err) + } + if result != "key=secret123 expr=evaluated:hello" { + t.Errorf("expected both resolved, got %q", result) + } +} + +func TestResolveFuncOverridesBackend(t *testing.T) { + r := NewResolver() + r.Register("test", &mockBackend{secrets: map[string]string{"key": "from_backend"}}) + r.RegisterFunc("test", func(content string) (string, error) { + return "from_func", nil + }) + + // Func takes priority over backend + result, err := r.Resolve("{{test:key}}") + if err != nil { + t.Fatal(err) + } + if result != "from_func" { + t.Errorf("expected func to take priority, got %q", result) + } +} + +func TestResolveFuncNotTracked(t *testing.T) { + r := NewResolver() + r.RegisterFunc("expr", func(content string) (string, error) { + return "resolved", nil + }) + + _, accessed, err := r.ResolveTracked("{{expr:something}}") + if err != nil { + t.Fatal(err) + } + if len(accessed) != 0 { + t.Errorf("expr funcs should not be tracked, got %v", accessed) + } +} + +func TestResolveFuncError(t *testing.T) { + r := NewResolver() + r.RegisterFunc("expr", func(content string) (string, error) { + return "", fmt.Errorf("eval error") + }) + + // With no default, should leave pattern unchanged + result, _ := r.Resolve("{{expr:bad}}") + if result != "{{expr:bad}}" { + t.Errorf("expected unchanged on error, got %q", result) + } +} + +func TestResolveFuncWithDefault(t *testing.T) { + r := NewResolver() + r.RegisterFunc("expr", func(content string) (string, error) { + return "", fmt.Errorf("eval error") + }) + + result, _ := r.Resolve("{{expr:bad|fallback}}") + if result != "fallback" { + t.Errorf("expected fallback on error, got %q", result) + } +} + +func TestResolveFuncComplexExpr(t *testing.T) { + r := NewResolver() + r.RegisterFunc("expr", func(content string) (string, error) { + // Verify the full expression content is passed through + return "got:" + content, nil + }) + + result, _ := r.Resolve("{{expr:jsonpath(data, '$.items[0].name')}}") + if result != "got:jsonpath(data, '$.items[0].name')" { + t.Errorf("expected full expression passed, got %q", result) + } +} From 0572989cbd9318793d5dbb173512bd589022a6ab Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Fri, 8 May 2026 23:13:35 -0400 Subject: [PATCH 48/65] Update docs for resolver backends --- docs/expressions.md | 25 ++++++++++++++++++++++--- docs/vault.md | 14 ++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/docs/expressions.md b/docs/expressions.md index e12b5cc..4cd913c 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -243,9 +243,27 @@ Use `condition: "true"` as a default/else case. Each switch case supports `tool`, `params`, and `store` (same as a regular step). If no condition matches, the switch step is skipped. -## Value expressions in step params +## Value expressions -Use `{{expr:...}}` in step param values to transform variables before passing them to the next step. The expression is evaluated and its **string result** is substituted. +Use `{{expr:...}}` in parameter values to evaluate functions inline. Works in **any tool call** (CLI, REST, MCP, workflows) — not just workflow steps. + +```yaml +tools: + my.api: + type: rest + base_url: https://api.example.com + method: GET + path: /events + parameters: + - name: since + default: "{{expr:now('-24h')}}" +``` + +The `{{expr:...}}` pattern is a resolver backend — it works alongside `{{vault:KEY}}` and `{{env:VAR}}` in the same string, all resolved in one pass. + +### In workflow step params + +Expressions are most powerful in workflows where they can reference stored step outputs: ```yaml steps: @@ -261,11 +279,12 @@ steps: count: "{{expr:len(events)}}" ``` -The `{{expr:...}}` prefix distinguishes expressions from simple variable substitution (`{{variable}}`). Both can be mixed in the same string: +Mix `{{expr:...}}` with simple `{{variable}}` substitution and `{{vault:KEY}}` references in the same string: ```yaml params: message: "Hello {{name}}, you have {{expr:len(items)}} items" + auth: "{{vault:API_KEY}}" ``` ### Value functions diff --git a/docs/vault.md b/docs/vault.md index 31a99f9..f4ee650 100644 --- a/docs/vault.md +++ b/docs/vault.md @@ -33,10 +33,24 @@ token: "{{env:GITHUB_TOKEN}}" # Vault (encrypted on disk, decrypted on demand) token: "{{vault:GITHUB_TOKEN}}" + +# With a default fallback +base_url: "{{vault:API_URL|https://api.example.com}}" ``` Both work. Mix and match per tool. Vault references are resolved at call time — the agent never sees either form. +All `{{prefix:content}}` references use the same resolver pattern. Available backends: + +| Prefix | Source | Example | +|--------|--------|---------| +| `vault` | Encrypted local vault | `{{vault:GITHUB_TOKEN}}` | +| `env` | Environment variable | `{{env:HOME}}` | +| `op` | 1Password (external backend) | `{{op:ABCDEF}}` | +| `expr` | [Expression function](expressions.md#value-expressions) | `{{expr:now()}}` | + +All can be mixed in a single string: `{{vault:TOKEN}} expires {{expr:now('24h')}}`. + ## How it works ``` From 1447901e968b16b3a9b20092e377c1e83d3dc8e2 Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Sat, 9 May 2026 00:33:13 -0400 Subject: [PATCH 49/65] Add today(), left(), right(), find(), cut(), concat() expression functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6 new functions for the expression engine, bringing the total to 20. Includes alphabetized docs, practical workflow example, and 21 new tests. New functions: - today(): date in YYYY-MM-DD format, with optional offset as duration ('24h') or day count ('7', '-1') - left(val, n): first n characters with bounds clamping - right(val, n): last n characters with bounds clamping - find(val, needle): index of substring, returns -1 if not found - cut(val, delim): split by delimiter → JSON array - cut(val, delim, index): split and pick field, supports negative indexing ('-1' for last element) - concat(a, b, ...): variadic concatenation, works with nested function calls like concat(upper(name), '@example.com') Documentation: - expressions.md: function table and detail sections alphabetized (concat → cut → default → ... → trim → upper) - New example 33-workflow-expressions.md: daily calendar summary, JSON extraction, date-based API calls, string manipulation, conditional alerting - Added to examples/README.md Tests (21 new): - today: no arg, duration offset, day count, negative - left: basic, beyond length, empty - right: basic, beyond length, empty - find: found, not found, empty needle - cut: indexed (3 fields), negative index, no index (array), out of bounds - concat: multi-arg, single, empty, nested with upper() --- docs/examples/33-workflow-expressions.md | 121 +++++++++++++++++ docs/examples/README.md | 1 + docs/expressions.md | 81 ++++++++---- src/internal/provider/expr.go | 87 ++++++++++++ src/internal/provider/expr_test.go | 160 +++++++++++++++++++++++ 5 files changed, 426 insertions(+), 24 deletions(-) create mode 100644 docs/examples/33-workflow-expressions.md diff --git a/docs/examples/33-workflow-expressions.md b/docs/examples/33-workflow-expressions.md new file mode 100644 index 0000000..2098cb6 --- /dev/null +++ b/docs/examples/33-workflow-expressions.md @@ -0,0 +1,121 @@ +# Workflow Expressions + +Use `{{expr:...}}` in step params to transform data between steps — extract JSON fields, format timestamps, manipulate strings. + +## Config + +```yaml +# .factorly/factorly.yaml +tools: + daily.summary: + type: workflow + description: Fetch today's calendar events and post a summary to Slack + parameters: + - name: channel + default: "#general" + steps: + - tool: google-calendar.list_events + params: + calendarId: primary + timeMin: "{{expr:now()}}" + timeMax: "{{expr:now('24h')}}" + maxResults: "10" + store: events + + - tool: slack.post_message + if: "jsonpath(events, '$.items') != ''" + params: + channel: "{{channel}}" + text: "{{expr:concat('You have ', len(events), ' events today')}}" +``` + +## Run it + +```bash +factorly call daily.summary +factorly call daily.summary --channel '#engineering' +``` + +## More examples + +### Extract and transform JSON + +```yaml +steps: + - tool: api.get_user + params: + id: "{{user_id}}" + store: user + + - tool: notification.send + params: + # Extract nested JSON field + email: "{{expr:jsonpath(user, '$.contact.email')}}" + # Uppercase the name + greeting: "{{expr:concat('Hello ', upper(jsonpath(user, '$.name')))}}" + # Default if missing + role: "{{expr:default(jsonpath(user, '$.role'), 'member')}}" +``` + +### Date-based API calls + +```yaml +steps: + - tool: analytics.query + params: + start_date: "{{expr:today('-7')}}" + end_date: "{{expr:today()}}" + store: metrics + + - tool: report.generate + params: + title: "{{expr:concat('Weekly Report: ', today('-7'), ' to ', today())}}" + data: "{{metrics}}" +``` + +### String manipulation + +```yaml +steps: + - tool: git.log + store: log_output + + - tool: slack.post + params: + # First 500 chars of output + text: "{{expr:left(log_output, 500)}}" + # Extract filename from path + filename: "{{expr:cut('/home/user/docs/report.pdf', '/', '-1')}}" + # Clean up whitespace + summary: "{{expr:trim(log_output)}}" +``` + +### Conditional with expressions + +```yaml +steps: + - tool: api.health_check + store: health + + - tool: pagerduty.alert + # Only alert if status is not healthy + if: "jsonpath(health, '$.status') != 'healthy'" + params: + severity: "critical" + message: "{{expr:concat('Service down: ', jsonpath(health, '$.message'))}}" +``` + +## Available functions + +See the full [expressions reference](../expressions.md#value-functions) for all 20 functions: `concat`, `cut`, `default`, `find`, `join`, `jsonpath`, `left`, `len`, `lower`, `now`, `replace`, `right`, `substr`, `today`, `trim`, `upper`, and more. + +## How it works + +1. `{{expr:...}}` is a resolver backend — same system as `{{vault:KEY}}` and `{{env:VAR}}` +2. In workflows, expressions have access to stored step outputs and input params +3. In any tool call, stateless functions like `now()`, `today()`, `upper()` work +4. Expressions that fail return empty string — no workflow crashes + +--- + +[← Back to Examples](README.md) diff --git a/docs/examples/README.md b/docs/examples/README.md index f99cc09..08a4cb7 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -72,6 +72,7 @@ Practical, copy-paste examples for every Factorly feature. Each is self-containe | 30 | [Workflows](30-workflows.md) | Chain tools into governed pipelines with variable passing | | 31 | [Workflow Conditionals](31-workflow-conditionals.md) | Branch with `if:` and `switch:` using expressions and jsonpath | | 32 | [Changelog Workflow](32-changelog-workflow.md) | Gather git log, diff stats, and contributors in one call | +| 33 | [Workflow Expressions](33-workflow-expressions.md) | Transform data between steps with `now()`, `jsonpath()`, `concat()`, and more | ## Compliance diff --git a/docs/expressions.md b/docs/expressions.md index 4cd913c..70d14e3 100644 --- a/docs/expressions.md +++ b/docs/expressions.md @@ -291,18 +291,26 @@ params: | Function | Description | Example | |----------|-------------|---------| -| `now()` | Current UTC timestamp (RFC3339) | `now()` → `2026-05-08T12:00:00Z` | -| `now(offset)` | Timestamp with offset | `now('24h')`, `now('-1h')`, `now('30m')` | -| `jsonpath(var, path)` | Extract from JSON | `jsonpath(data, '$.items[0].name')` | +| `concat(a, b, ...)` | Concatenate values | `concat(first, ' ', last)` → `John Doe` | +| `cut(val, delim)` | Split by delimiter → JSON array | `cut(csv, ',')` → `["a","b"]` | +| `cut(val, delim, index)` | Split and pick field | `cut(path, '/', '-1')` → last segment | | `default(var, fallback)` | Value or fallback if empty | `default(title, 'Untitled')` | -| `upper(val)` | Uppercase | `upper(name)` → `JORDAN` | -| `lower(val)` | Lowercase | `lower(name)` → `jordan` | -| `trim(val)` | Strip whitespace | `trim(output)` | +| `find(val, needle)` | Index of substring (-1 if not found) | `find(path, '/')` → `4` | +| `join(val, sep)` | Join JSON array | `join(tags, ', ')` → `go, rust` | +| `jsonpath(var, path)` | Extract from JSON | `jsonpath(data, '$.items[0].name')` | +| `left(val, n)` | First n characters | `left(id, 8)` → `abcdefgh` | | `len(val)` | Length (array, object, or string) | `len(items)` → `3` | +| `lower(val)` | Lowercase | `lower(name)` → `jordan` | +| `now()` | Current UTC timestamp (RFC3339) | `now()` → `2026-05-08T12:00:00Z` | +| `now(offset)` | Timestamp with offset | `now('24h')`, `now('-1h')`, `now('30m')` | +| `replace(val, old, new)` | String replacement | `replace(output, '\n', ' ')` | +| `right(val, n)` | Last n characters | `right(hash, 7)` → `a1b2c3d` | | `substr(val, start)` | Substring from position | `substr(id, 0, 8)` | | `substr(val, start, end)` | Substring with end | `substr(hash, 0, 7)` | -| `join(val, sep)` | Join JSON array | `join(tags, ', ')` → `go, rust` | -| `replace(val, old, new)` | String replacement | `replace(output, '\n', ' ')` | +| `today()` | Today's date (YYYY-MM-DD) | `today()` → `2026-05-09` | +| `today(offset)` | Date with offset | `today('7')` → 7 days from now | +| `trim(val)` | Strip whitespace | `trim(output)` | +| `upper(val)` | Uppercase | `upper(name)` → `JORDAN` | These functions also work in `if:`/`require:` conditions and `switch:` cases — they return values that can be compared: @@ -312,17 +320,29 @@ if: "jsonpath(data, '$.status') == 'ok'" if: "lower(env) == 'prod'" ``` -### `now()` offsets +### `cut()` behavior -The offset uses Go duration syntax: `h` (hours), `m` (minutes), `s` (seconds). Prefix with `-` for past. +Splits a string by delimiter. Without an index, returns a JSON array. With an index, returns the specific field. Supports negative indexing (`'-1'` for last). -| Offset | Result | -|--------|--------| -| `now()` | Current time | -| `now('24h')` | 24 hours from now | -| `now('-1h')` | 1 hour ago | -| `now('30m')` | 30 minutes from now | -| `now('1h30m')` | 90 minutes from now | +```yaml +# path = "a/b/c/d" +cut(path, '/') # → ["a","b","c","d"] +cut(path, '/', 0) # → "a" +cut(path, '/', '-1') # → "d" + +# csv = "name,age,city" +cut(csv, ',', 1) # → "age" +``` + +### `join()` behavior + +Parses the value as a JSON array and joins elements with the separator. Non-array values pass through unchanged. + +```yaml +# tags = ["go", "rust", "python"] +join(tags, ', ') # → "go, rust, python" +join(tags, ' | ') # → "go | rust | python" +``` ### `len()` behavior @@ -333,15 +353,28 @@ The offset uses Go duration syntax: `h` (hours), `m` (minutes), `s` (seconds). P | Plain string `"hello"` | `5` | | Empty `""` | `0` | -### `join()` behavior +### `now()` offsets -Parses the value as a JSON array and joins elements with the separator. Non-array values pass through unchanged. +The offset uses Go duration syntax: `h` (hours), `m` (minutes), `s` (seconds). Prefix with `-` for past. -```yaml -# tags = ["go", "rust", "python"] -join(tags, ', ') # → "go, rust, python" -join(tags, ' | ') # → "go | rust | python" -``` +| Offset | Result | +|--------|--------| +| `now()` | Current time | +| `now('24h')` | 24 hours from now | +| `now('-1h')` | 1 hour ago | +| `now('30m')` | 30 minutes from now | +| `now('1h30m')` | 90 minutes from now | + +### `today()` offsets + +Returns the date in `YYYY-MM-DD` format. Accepts a duration or day count as offset. + +| Offset | Result | +|--------|--------| +| `today()` | Today | +| `today('7')` | 7 days from now | +| `today('-1')` | Yesterday | +| `today('48h')` | 2 days from now (duration syntax) | ## Error handling diff --git a/src/internal/provider/expr.go b/src/internal/provider/expr.go index 4bb7b53..7aedd48 100644 --- a/src/internal/provider/expr.go +++ b/src/internal/provider/expr.go @@ -478,10 +478,97 @@ func evalCall(name string, args []node, vars map[string]string) any { new, _ := evaluate(args[2], vars) return strings.ReplaceAll(toString(val), toString(old), toString(new)) } + case "today": + return evalToday(args, vars) + case "left": + if len(args) >= 2 { + val, _ := evaluate(args[0], vars) + n, _ := evaluate(args[1], vars) + s := toString(val) + count, _ := strconv.Atoi(fmt.Sprint(n)) + if count > len(s) { + count = len(s) + } + if count < 0 { + count = 0 + } + return s[:count] + } + case "right": + if len(args) >= 2 { + val, _ := evaluate(args[0], vars) + n, _ := evaluate(args[1], vars) + s := toString(val) + count, _ := strconv.Atoi(fmt.Sprint(n)) + if count > len(s) { + count = len(s) + } + if count < 0 { + count = 0 + } + return s[len(s)-count:] + } + case "find": + if len(args) >= 2 { + val, _ := evaluate(args[0], vars) + needle, _ := evaluate(args[1], vars) + idx := strings.Index(toString(val), toString(needle)) + return idx + } + case "cut": + if len(args) >= 2 { + val, _ := evaluate(args[0], vars) + delim, _ := evaluate(args[1], vars) + return evalCut(toString(val), toString(delim), args, vars) + } + case "concat": + var sb strings.Builder + for _, arg := range args { + val, _ := evaluate(arg, vars) + sb.WriteString(toString(val)) + } + return sb.String() } return false } +func evalToday(args []node, vars map[string]string) string { + t := time.Now().UTC() + if len(args) >= 1 { + offset, _ := evaluate(args[0], vars) + s := toString(offset) + // Try duration first (e.g., '24h', '-48h') + if d, err := time.ParseDuration(s); err == nil { + t = t.Add(d) + } else { + // Try day count (e.g., '1', '-7') + if days, err := strconv.Atoi(s); err == nil { + t = t.AddDate(0, 0, days) + } + } + } + return t.Format("2006-01-02") +} + +func evalCut(s, delim string, args []node, vars map[string]string) string { + parts := strings.Split(s, delim) + if len(args) >= 3 { + // cut(val, delim, index) — return specific field + idxVal, _ := evaluate(args[2], vars) + idx, _ := strconv.Atoi(fmt.Sprint(idxVal)) + if idx < 0 { + idx = len(parts) + idx // negative indexing + } + if idx >= 0 && idx < len(parts) { + return parts[idx] + } + return "" + } + // No index — return as JSON array + b, _ := json.Marshal(parts) + return string(b) +} + func evalNow(args []node, vars map[string]string) string { t := time.Now().UTC() if len(args) >= 1 { diff --git a/src/internal/provider/expr_test.go b/src/internal/provider/expr_test.go index 224a507..78c4320 100644 --- a/src/internal/provider/expr_test.go +++ b/src/internal/provider/expr_test.go @@ -1057,3 +1057,163 @@ func TestEvalExprDefaultBothEmpty(t *testing.T) { t.Errorf("expected empty, got %q", r) } } + +// --- today(), left(), right(), find(), cut() tests --- + +func TestEvalExprToday(t *testing.T) { + result := EvalExpr("today()", nil) + expected := time.Now().UTC().Format("2006-01-02") + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestEvalExprTodayOffset(t *testing.T) { + result := EvalExpr("today('24h')", nil) + expected := time.Now().UTC().Add(24 * time.Hour).Format("2006-01-02") + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestEvalExprTodayDayCount(t *testing.T) { + result := EvalExpr("today('7')", nil) + expected := time.Now().UTC().AddDate(0, 0, 7).Format("2006-01-02") + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestEvalExprTodayNegative(t *testing.T) { + result := EvalExpr("today('-1')", nil) + expected := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02") + if result != expected { + t.Errorf("expected yesterday %q, got %q", expected, result) + } +} + +func TestEvalExprLeft(t *testing.T) { + vars := map[string]string{"s": "abcdefgh"} + if r := EvalExpr("left(s, 3)", vars); r != "abc" { + t.Errorf("expected 'abc', got %q", r) + } +} + +func TestEvalExprLeftBeyondLength(t *testing.T) { + vars := map[string]string{"s": "ab"} + if r := EvalExpr("left(s, 10)", vars); r != "ab" { + t.Errorf("expected 'ab', got %q", r) + } +} + +func TestEvalExprLeftEmpty(t *testing.T) { + vars := map[string]string{"s": ""} + if r := EvalExpr("left(s, 3)", vars); r != "" { + t.Errorf("expected empty, got %q", r) + } +} + +func TestEvalExprRight(t *testing.T) { + vars := map[string]string{"s": "abcdefgh"} + if r := EvalExpr("right(s, 3)", vars); r != "fgh" { + t.Errorf("expected 'fgh', got %q", r) + } +} + +func TestEvalExprRightBeyondLength(t *testing.T) { + vars := map[string]string{"s": "ab"} + if r := EvalExpr("right(s, 10)", vars); r != "ab" { + t.Errorf("expected 'ab', got %q", r) + } +} + +func TestEvalExprRightEmpty(t *testing.T) { + vars := map[string]string{"s": ""} + if r := EvalExpr("right(s, 3)", vars); r != "" { + t.Errorf("expected empty, got %q", r) + } +} + +func TestEvalExprFind(t *testing.T) { + vars := map[string]string{"s": "hello world"} + if r := EvalExpr("find(s, 'world')", vars); r != "6" { + t.Errorf("expected '6', got %q", r) + } +} + +func TestEvalExprFindNotFound(t *testing.T) { + vars := map[string]string{"s": "hello"} + if r := EvalExpr("find(s, 'xyz')", vars); r != "-1" { + t.Errorf("expected '-1', got %q", r) + } +} + +func TestEvalExprFindEmpty(t *testing.T) { + vars := map[string]string{"s": "hello"} + if r := EvalExpr("find(s, '')", vars); r != "0" { + t.Errorf("expected '0', got %q", r) + } +} + +func TestEvalExprCutWithIndex(t *testing.T) { + vars := map[string]string{"s": "one:two:three"} + if r := EvalExpr("cut(s, ':', 0)", vars); r != "one" { + t.Errorf("expected 'one', got %q", r) + } + if r := EvalExpr("cut(s, ':', 1)", vars); r != "two" { + t.Errorf("expected 'two', got %q", r) + } + if r := EvalExpr("cut(s, ':', 2)", vars); r != "three" { + t.Errorf("expected 'three', got %q", r) + } +} + +func TestEvalExprCutNegativeIndex(t *testing.T) { + vars := map[string]string{"s": "a/b/c/d"} + // Use string '-1' since the parser treats bare -1 as binary minus + if r := EvalExpr("cut(s, '/', '-1')", vars); r != "d" { + t.Errorf("expected 'd', got %q", r) + } +} + +func TestEvalExprCutNoIndex(t *testing.T) { + vars := map[string]string{"s": "a,b,c"} + r := EvalExpr("cut(s, ',')", vars) + if !strings.Contains(r, "a") || !strings.Contains(r, "b") || !strings.Contains(r, "c") { + t.Errorf("expected JSON array, got %q", r) + } +} + +func TestEvalExprCutOutOfBounds(t *testing.T) { + vars := map[string]string{"s": "a:b"} + if r := EvalExpr("cut(s, ':', 5)", vars); r != "" { + t.Errorf("expected empty for out of bounds, got %q", r) + } +} + +func TestEvalExprConcat(t *testing.T) { + vars := map[string]string{"first": "hello", "second": "world"} + if r := EvalExpr("concat(first, ' ', second)", vars); r != "hello world" { + t.Errorf("expected 'hello world', got %q", r) + } +} + +func TestEvalExprConcatSingle(t *testing.T) { + vars := map[string]string{"x": "solo"} + if r := EvalExpr("concat(x)", vars); r != "solo" { + t.Errorf("expected 'solo', got %q", r) + } +} + +func TestEvalExprConcatEmpty(t *testing.T) { + if r := EvalExpr("concat()", nil); r != "" { + t.Errorf("expected empty, got %q", r) + } +} + +func TestEvalExprConcatNested(t *testing.T) { + vars := map[string]string{"name": "jordan"} + if r := EvalExpr("concat(upper(name), '@example.com')", vars); r != "JORDAN@example.com" { + t.Errorf("expected 'JORDAN@example.com', got %q", r) + } +} From 55a52088bd8937df6bbe8c4d258a813641e6681a Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Sat, 9 May 2026 00:41:00 -0400 Subject: [PATCH 50/65] Fixed SSE on boot --- src/cmd/factorly/ui_cmd.go | 10 +++++++++- src/internal/ui/static/activity.js | 14 +++++++++++++- src/internal/ui/static/confirm.js | 13 ++++++++++++- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/cmd/factorly/ui_cmd.go b/src/cmd/factorly/ui_cmd.go index 3a0e1e9..7fa8bc1 100644 --- a/src/cmd/factorly/ui_cmd.go +++ b/src/cmd/factorly/ui_cmd.go @@ -8,6 +8,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "net" "net/http" "os" "os/exec" @@ -162,11 +163,18 @@ func runUI(cmd *cobra.Command, args []string) error { fmt.Fprintf(cmd.ErrOrStderr(), "MCP endpoint at http://localhost:%d/mcp (token: %s)\n", uiPort, uiMCPToken) } + // Start listener first, then open browser to avoid race condition + // where the browser connects before the server is ready + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + if !uiNoLaunch { go openBrowser(url) } - return http.ListenAndServe(addr, handler) + return http.Serve(ln, handler) } // hostValidation rejects requests with unexpected Host headers. diff --git a/src/internal/ui/static/activity.js b/src/internal/ui/static/activity.js index 8500e42..9c35a20 100644 --- a/src/internal/ui/static/activity.js +++ b/src/internal/ui/static/activity.js @@ -20,7 +20,14 @@ function clearActivity() { } // SSE connection (singleton — only create once across hx-boost navigations) -if (!window._activitySSE) { +// Use DOMContentLoaded to ensure page is ready; for hx-boost navigations +// the script re-executes after swap so the guard prevents duplicates. +function _initActivitySSE() { + // Reconnect if previous connection was closed (e.g., by auth redirect) + if (window._activitySSE && window._activitySSE.readyState === EventSource.CLOSED) { + window._activitySSE = null; + } + if (window._activitySSE) return; window._activitySSE = new EventSource('/activity/stream'); window._activitySSE.onmessage = function(event) { @@ -97,3 +104,8 @@ if (!window._activitySSE) { setTimeout(() => { dot.classList.remove('bg-indigo-500'); dot.classList.add('bg-green-500'); }, 300); }; } +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', _initActivitySSE); +} else { + _initActivitySSE(); +} diff --git a/src/internal/ui/static/confirm.js b/src/internal/ui/static/confirm.js index 0f9b4bc..8e94cad 100644 --- a/src/internal/ui/static/confirm.js +++ b/src/internal/ui/static/confirm.js @@ -2,7 +2,12 @@ // SPDX-License-Identifier: gpl // Confirm modal — SSE stream for shadow confirm prompts -if (!window._confirmSSE) { +function _initConfirmSSE() { + // Reconnect if previous connection was closed (e.g., by auth redirect) + if (window._confirmSSE && window._confirmSSE.readyState === EventSource.CLOSED) { + window._confirmSSE = null; + } + if (window._confirmSSE) return; window._confirmSSE = new EventSource('/confirm/stream'); window._confirmSSE.onmessage = function(event) { const pending = JSON.parse(event.data); @@ -40,3 +45,9 @@ function respondConfirm(id, action) { fetch(`/confirm/${id}/${action}`, { method: 'POST' }); document.getElementById('confirm-modal').classList.add('hidden'); } + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', _initConfirmSSE); +} else { + _initConfirmSSE(); +} From e9cf353774f5f74ce7e46e457752f789c83ba519 Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Sat, 9 May 2026 00:48:57 -0400 Subject: [PATCH 51/65] Update UI for consistency between tools and workflows. Updated confirmation to use modal instead of browser native --- src/internal/ui/templates/layout.html | 43 ++++++++++++++++++- .../ui/templates/partials/try_panel.html | 6 +-- src/internal/ui/templates/tool_edit.html | 4 +- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/internal/ui/templates/layout.html b/src/internal/ui/templates/layout.html index 56e5554..0af9ef1 100644 --- a/src/internal/ui/templates/layout.html +++ b/src/internal/ui/templates/layout.html @@ -150,10 +150,51 @@ - + + + + + + {{end}} diff --git a/src/internal/ui/templates/partials/try_panel.html b/src/internal/ui/templates/partials/try_panel.html index 396fad0..779843d 100644 --- a/src/internal/ui/templates/partials/try_panel.html +++ b/src/internal/ui/templates/partials/try_panel.html @@ -27,11 +27,11 @@

Try it

- {{icon "send"}} Send + class="w-full px-4 py-2.5 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors shadow-sm inline-flex items-center justify-center gap-2"> + {{icon "play"}} Run - running... + executing... diff --git a/src/internal/ui/templates/tool_edit.html b/src/internal/ui/templates/tool_edit.html index 651d8df..be6a442 100644 --- a/src/internal/ui/templates/tool_edit.html +++ b/src/internal/ui/templates/tool_edit.html @@ -420,8 +420,8 @@

Try it

- {{icon "send"}} Send + class="w-full px-4 py-2.5 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors shadow-sm inline-flex items-center justify-center gap-2"> + {{icon "play"}} Run running... From 773fa42e8e34ccea3f9b79618b82de61d08775e3 Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Sat, 9 May 2026 09:37:24 -0400 Subject: [PATCH 52/65] Group sidebar tools by dot-prefix, persist collapse state, fix tool rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group tools and workflows in the sidebar by their dot-separated prefix (e.g., github.list_repos → GITHUB group). Persist collapse/expand state in localStorage. Fix tool rename htmx target error. Sidebar grouping (handlers_tools.go): - Add toolGroup struct (Prefix + []toolListItem) and groupTools() - Splits tool names on first dot to derive prefix - Ungrouped tools (no dot) appear flat at top, groups sorted alphabetically - render() injects SidebarToolGroups/SidebarWorkflowGroups instead of flat lists, plus ActivePrefix for auto-opening the active group Layout template (layout.html): - Tools and workflows sidebars use
groups with prefix headers showing group name and tool count - Groups start open by default - Grouped items indented (pl-7) for visual hierarchy - Ungrouped items render flat as before Collapse state persistence: - localStorage key 'sidebarGroups' stores {prefix: bool} map - On page load, restores collapsed groups - Toggle event listener saves state on collapse/expand - Survives hx-boost navigations and full page reloads Filter update: - filterTools() now hides empty groups when filtering - Auto-expands matching groups during search Tool rename fix: - Changed hx-target="#tool-header-area" (nonexistent) to hx-swap="none" - Rename handler returns HX-Redirect so no swap target needed - Matches workflow rename pattern UI consistency: - Standardized button labels: "Create New Tool/Workflow/Provider" - Added {{icon "plus"}} to add buttons across templates --- src/internal/ui/handlers_tools.go | 57 +++++++++++++- src/internal/ui/templates/auth.html | 2 +- src/internal/ui/templates/layout.html | 77 +++++++++++++++++-- .../ui/templates/partials/workflow_step.html | 2 +- src/internal/ui/templates/tool_edit.html | 6 +- src/internal/ui/templates/tools.html | 2 +- src/internal/ui/templates/workflow_edit.html | 6 +- src/internal/ui/templates/workflows.html | 4 +- 8 files changed, 135 insertions(+), 21 deletions(-) diff --git a/src/internal/ui/handlers_tools.go b/src/internal/ui/handlers_tools.go index fff74a6..928f949 100644 --- a/src/internal/ui/handlers_tools.go +++ b/src/internal/ui/handlers_tools.go @@ -26,6 +26,41 @@ type toolListItem struct { Hidden bool } +type toolGroup struct { + Prefix string + Tools []toolListItem +} + +func groupTools(items []toolListItem) []toolGroup { + groups := make(map[string][]toolListItem) + var order []string + for _, item := range items { + prefix := "" + if i := strings.Index(item.Name, "."); i > 0 { + prefix = item.Name[:i] + } + if _, exists := groups[prefix]; !exists { + order = append(order, prefix) + } + groups[prefix] = append(groups[prefix], item) + } + // Sort: ungrouped first, then alphabetical + sort.Slice(order, func(i, j int) bool { + if order[i] == "" { + return true + } + if order[j] == "" { + return false + } + return order[i] < order[j] + }) + result := make([]toolGroup, len(order)) + for i, prefix := range order { + result[i] = toolGroup{Prefix: prefix, Tools: groups[prefix]} + } + return result +} + func (s *Server) handleToolsList(w http.ResponseWriter, r *http.Request) { var tools []toolListItem for name, tc := range s.cfg.Tools { @@ -585,16 +620,30 @@ func (s *Server) render(w http.ResponseWriter, name string, data any) { } if m, ok := data.(map[string]any); ok { + // Ensure ActivePrefix is always set (templates need it) + if _, has := m["ActivePrefix"]; !has { + m["ActivePrefix"] = "" + } // Inject sidebar tools for tools-related pages if m["Nav"] == "tools" { - if _, has := m["SidebarTools"]; !has { - m["SidebarTools"] = s.getSidebarTools() + if _, has := m["SidebarToolGroups"]; !has { + m["SidebarToolGroups"] = groupTools(s.getSidebarTools()) + } + if active, ok := m["ActiveTool"].(string); ok { + if i := strings.Index(active, "."); i > 0 { + m["ActivePrefix"] = active[:i] + } } } // Inject sidebar workflows for workflow pages if m["Nav"] == "workflows" { - if _, has := m["SidebarWorkflows"]; !has { - m["SidebarWorkflows"] = s.getSidebarWorkflows() + if _, has := m["SidebarWorkflowGroups"]; !has { + m["SidebarWorkflowGroups"] = groupTools(s.getSidebarWorkflows()) + } + if active, ok := m["ActiveTool"].(string); ok { + if i := strings.Index(active, "."); i > 0 { + m["ActivePrefix"] = active[:i] + } } } } diff --git a/src/internal/ui/templates/auth.html b/src/internal/ui/templates/auth.html index ec9ed37..38aa918 100644 --- a/src/internal/ui/templates/auth.html +++ b/src/internal/ui/templates/auth.html @@ -7,7 +7,7 @@

Auth Providers

+ class="px-3 py-1.5 text-xs text-indigo-600 border border-indigo-200 rounded hover:bg-indigo-50 transition-colors">{{icon "plus"}} Create New Provider
diff --git a/src/internal/ui/templates/layout.html b/src/internal/ui/templates/layout.html index 0af9ef1..3e4a74f 100644 --- a/src/internal/ui/templates/layout.html +++ b/src/internal/ui/templates/layout.html @@ -65,16 +65,34 @@ oninput="filterTools(this.value)">
{{else if eq .Nav "workflows"}} @@ -86,15 +104,32 @@ oninput="filterTools(this.value)">
{{end}} @@ -133,12 +168,42 @@
{{end}} From a727f4bb68ecff327d4acf15a816f858616123e8 Mon Sep 17 00:00:00 2001 From: Jordan Sherer Date: Sun, 10 May 2026 23:53:46 -0400 Subject: [PATCH 60/65] Fixed non-JSON text now gets text-green-300 (success) or text-amber-300 (error) on the dark background. --- src/internal/ui/templates/partials/try_result.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/ui/templates/partials/try_result.html b/src/internal/ui/templates/partials/try_result.html index aea7cf1..7aff854 100644 --- a/src/internal/ui/templates/partials/try_result.html +++ b/src/internal/ui/templates/partials/try_result.html @@ -51,7 +51,7 @@
-
{{.FormattedOutput}}
+
{{.FormattedOutput}}