From e36110d2a22d99aaf8d2e6271ded81fbf8c5ca73 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Sun, 15 Mar 2026 17:47:32 +1100 Subject: [PATCH 1/4] feat: add workflow create, delete, update commands and MCP workflow tools --- cmd/serve/serve.go | 2 + cmd/serve/tools.go | 324 +++++++++++++++++++++++++++++++++++++++ cmd/workflow/create.go | 169 ++++++++++++++++++++ cmd/workflow/delete.go | 92 +++++++++++ cmd/workflow/update.go | 129 ++++++++++++++++ cmd/workflow/workflow.go | 3 + 6 files changed, 719 insertions(+) create mode 100644 cmd/workflow/create.go create mode 100644 cmd/workflow/delete.go create mode 100644 cmd/workflow/update.go diff --git a/cmd/serve/serve.go b/cmd/serve/serve.go index 11bfd49..3fb531a 100644 --- a/cmd/serve/serve.go +++ b/cmd/serve/serve.go @@ -61,5 +61,7 @@ func runServeMCP(f *cmdutil.Factory) error { fmt.Fprintf(f.IOStreams.ErrOut, "Warning: tool registration failed: %v\n", err) } + registerStaticTools(server, f) + return server.Run(context.Background(), &mcp.StdioTransport{}) } diff --git a/cmd/serve/tools.go b/cmd/serve/tools.go index 40a6e6c..d5ae677 100644 --- a/cmd/serve/tools.go +++ b/cmd/serve/tools.go @@ -128,3 +128,327 @@ func MakeToolHandler(f *cmdutil.Factory, actionType string) mcp.ToolHandler { }, nil } } + +// getStringArg extracts a string value from the args map. +func getStringArg(args map[string]any, key string) string { + if v, ok := args[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// makeStaticHandler creates a handler for a static tool that calls the +// KeeperHub API directly with a configurable HTTP method and URL pattern. +func makeStaticHandler( + f *cmdutil.Factory, + method string, + buildURL func(args map[string]any, baseURL string) string, + buildBody func(args map[string]any) ([]byte, error), +) mcp.ToolHandler { + return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args map[string]any + if req.Params.Arguments != nil { + if unmarshalErr := json.Unmarshal(req.Params.Arguments, &args); unmarshalErr != nil { + return nil, fmt.Errorf("unmarshaling arguments: %w", unmarshalErr) + } + } + if args == nil { + args = make(map[string]any) + } + + client, err := f.HTTPClient() + if err != nil { + return nil, fmt.Errorf("creating HTTP client: %w", err) + } + + cfg, err := f.Config() + if err != nil { + return nil, fmt.Errorf("reading config: %w", err) + } + + baseURL := khhttp.BuildBaseURL(cmdutil.ResolveHost(nil, cfg)) + targetURL := buildURL(args, baseURL) + + var body io.Reader + if buildBody != nil { + bodyBytes, bodyErr := buildBody(args) + if bodyErr != nil { + return nil, fmt.Errorf("building request body: %w", bodyErr) + } + body = bytes.NewReader(bodyBytes) + } + + httpReq, err := client.NewRequest(method, targetURL, body) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + if body != nil { + httpReq.Header.Set("Content-Type", "application/json") + } + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("executing request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + + if resp.StatusCode >= 400 { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(respBody))}, + }, + IsError: true, + }, nil + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(respBody)}, + }, + }, nil + } +} + +// registerStaticTools registers workflow management and execution tools that +// call KeeperHub API endpoints directly (not via /api/execute/). +func registerStaticTools(server *mcp.Server, f *cmdutil.Factory) { + // workflow_list -- GET /api/workflows + server.AddTool(&mcp.Tool{ + Name: "workflow_list", + Description: "List all workflows in the current organization", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "limit": map[string]any{ + "type": "string", + "description": "Maximum number of workflows to return", + }, + }, + }, + }, makeStaticHandler(f, http.MethodGet, func(args map[string]any, baseURL string) string { + u := baseURL + "/api/workflows" + if limit := getStringArg(args, "limit"); limit != "" { + u += "?limit=" + limit + } + return u + }, nil)) + + // workflow_get -- GET /api/workflows/{id} + server.AddTool(&mcp.Tool{ + Name: "workflow_get", + Description: "Get a workflow by ID, including its nodes and edges", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "workflow_id": map[string]any{ + "type": "string", + "description": "The workflow ID", + }, + }, + "required": []string{"workflow_id"}, + }, + }, makeStaticHandler(f, http.MethodGet, func(args map[string]any, baseURL string) string { + return baseURL + "/api/workflows/" + getStringArg(args, "workflow_id") + }, nil)) + + // workflow_create -- POST /api/workflows/create + server.AddTool(&mcp.Tool{ + Name: "workflow_create", + Description: "Create a new workflow", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + "description": "Workflow name", + }, + "description": map[string]any{ + "type": "string", + "description": "Workflow description", + }, + "nodes": map[string]any{ + "type": "string", + "description": "JSON string of the nodes array", + }, + "edges": map[string]any{ + "type": "string", + "description": "JSON string of the edges array", + }, + }, + "required": []string{"name"}, + }, + }, makeStaticHandler(f, http.MethodPost, func(args map[string]any, baseURL string) string { + return baseURL + "/api/workflows/create" + }, func(args map[string]any) ([]byte, error) { + body := map[string]any{ + "name": getStringArg(args, "name"), + } + if desc := getStringArg(args, "description"); desc != "" { + body["description"] = desc + } + if nodesStr := getStringArg(args, "nodes"); nodesStr != "" { + var nodes []interface{} + if err := json.Unmarshal([]byte(nodesStr), &nodes); err != nil { + return nil, fmt.Errorf("parsing nodes JSON: %w", err) + } + body["nodes"] = nodes + } + if edgesStr := getStringArg(args, "edges"); edgesStr != "" { + var edges []interface{} + if err := json.Unmarshal([]byte(edgesStr), &edges); err != nil { + return nil, fmt.Errorf("parsing edges JSON: %w", err) + } + body["edges"] = edges + } + return json.Marshal(body) + })) + + // workflow_update -- PATCH /api/workflows/{id} + server.AddTool(&mcp.Tool{ + Name: "workflow_update", + Description: "Update an existing workflow", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "workflow_id": map[string]any{ + "type": "string", + "description": "The workflow ID to update", + }, + "name": map[string]any{ + "type": "string", + "description": "New workflow name", + }, + "description": map[string]any{ + "type": "string", + "description": "New workflow description", + }, + "nodes": map[string]any{ + "type": "string", + "description": "JSON string of the nodes array", + }, + "edges": map[string]any{ + "type": "string", + "description": "JSON string of the edges array", + }, + }, + "required": []string{"workflow_id"}, + }, + }, makeStaticHandler(f, http.MethodPatch, func(args map[string]any, baseURL string) string { + return baseURL + "/api/workflows/" + getStringArg(args, "workflow_id") + }, func(args map[string]any) ([]byte, error) { + body := make(map[string]any) + if name := getStringArg(args, "name"); name != "" { + body["name"] = name + } + if desc := getStringArg(args, "description"); desc != "" { + body["description"] = desc + } + if nodesStr := getStringArg(args, "nodes"); nodesStr != "" { + var nodes []interface{} + if err := json.Unmarshal([]byte(nodesStr), &nodes); err != nil { + return nil, fmt.Errorf("parsing nodes JSON: %w", err) + } + body["nodes"] = nodes + } + if edgesStr := getStringArg(args, "edges"); edgesStr != "" { + var edges []interface{} + if err := json.Unmarshal([]byte(edgesStr), &edges); err != nil { + return nil, fmt.Errorf("parsing edges JSON: %w", err) + } + body["edges"] = edges + } + return json.Marshal(body) + })) + + // workflow_delete -- DELETE /api/workflows/{id} + server.AddTool(&mcp.Tool{ + Name: "workflow_delete", + Description: "Delete a workflow by ID", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "workflow_id": map[string]any{ + "type": "string", + "description": "The workflow ID to delete", + }, + }, + "required": []string{"workflow_id"}, + }, + }, makeStaticHandler(f, http.MethodDelete, func(args map[string]any, baseURL string) string { + return baseURL + "/api/workflows/" + getStringArg(args, "workflow_id") + }, nil)) + + // workflow_execute -- POST /api/workflow/{id}/execute + server.AddTool(&mcp.Tool{ + Name: "workflow_execute", + Description: "Execute a workflow by ID", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "workflow_id": map[string]any{ + "type": "string", + "description": "The workflow ID to execute", + }, + "input": map[string]any{ + "type": "string", + "description": "JSON string of input data for the execution", + }, + }, + "required": []string{"workflow_id"}, + }, + }, makeStaticHandler(f, http.MethodPost, func(args map[string]any, baseURL string) string { + return baseURL + "/api/workflow/" + getStringArg(args, "workflow_id") + "/execute" + }, func(args map[string]any) ([]byte, error) { + if inputStr := getStringArg(args, "input"); inputStr != "" { + var input map[string]interface{} + if err := json.Unmarshal([]byte(inputStr), &input); err != nil { + return nil, fmt.Errorf("parsing input JSON: %w", err) + } + return json.Marshal(input) + } + return []byte("{}"), nil + })) + + // execution_status -- GET /api/workflows/executions/{id}/status + server.AddTool(&mcp.Tool{ + Name: "execution_status", + Description: "Get the status of a workflow execution", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "execution_id": map[string]any{ + "type": "string", + "description": "The execution ID to check", + }, + }, + "required": []string{"execution_id"}, + }, + }, makeStaticHandler(f, http.MethodGet, func(args map[string]any, baseURL string) string { + return baseURL + "/api/workflows/executions/" + getStringArg(args, "execution_id") + "/status" + }, nil)) + + // execution_logs -- GET /api/workflows/executions/{id}/logs + server.AddTool(&mcp.Tool{ + Name: "execution_logs", + Description: "Get the logs for a workflow execution", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "execution_id": map[string]any{ + "type": "string", + "description": "The execution ID to get logs for", + }, + }, + "required": []string{"execution_id"}, + }, + }, makeStaticHandler(f, http.MethodGet, func(args map[string]any, baseURL string) string { + return baseURL + "/api/workflows/executions/" + getStringArg(args, "execution_id") + "/logs" + }, nil)) +} diff --git a/cmd/workflow/create.go b/cmd/workflow/create.go new file mode 100644 index 0000000..f8731df --- /dev/null +++ b/cmd/workflow/create.go @@ -0,0 +1,169 @@ +package workflow + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/jedib0t/go-pretty/v6/table" + khhttp "github.com/keeperhub/cli/internal/http" + "github.com/keeperhub/cli/internal/output" + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +type createRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Nodes []interface{} `json:"nodes,omitempty"` + Edges []interface{} `json:"edges,omitempty"` +} + +type createResponse struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"createdAt"` +} + +func NewCreateCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a workflow", + Aliases: []string{"new"}, + Args: cobra.NoArgs, + Example: ` # Create an empty workflow + kh wf create --name "My Workflow" + + # Create with nodes from a JSON file + kh wf create --name "DeFi Monitor" --nodes-file workflow.json + + # Create with inline JSON nodes + kh wf create --name "Test" --nodes '[{"id":"t1","type":"trigger","position":{"x":0,"y":0},"data":{"type":"trigger","config":{"triggerType":"Manual"}}}]'`, + RunE: func(cmd *cobra.Command, args []string) error { + name, err := cmd.Flags().GetString("name") + if err != nil { + return err + } + if name == "" { + return cmdutil.FlagError{Err: fmt.Errorf("--name is required")} + } + + description, err := cmd.Flags().GetString("description") + if err != nil { + return err + } + + nodesFile, err := cmd.Flags().GetString("nodes-file") + if err != nil { + return err + } + + nodesInline, err := cmd.Flags().GetString("nodes") + if err != nil { + return err + } + + edgesInline, err := cmd.Flags().GetString("edges") + if err != nil { + return err + } + + body := createRequest{ + Name: name, + Description: description, + } + + // Load nodes/edges from file if provided + if nodesFile != "" { + fileData, readErr := os.ReadFile(nodesFile) + if readErr != nil { + return fmt.Errorf("reading nodes file: %w", readErr) + } + + var fileContent struct { + Nodes []interface{} `json:"nodes"` + Edges []interface{} `json:"edges"` + } + if unmarshalErr := json.Unmarshal(fileData, &fileContent); unmarshalErr != nil { + return fmt.Errorf("parsing nodes file: %w", unmarshalErr) + } + body.Nodes = fileContent.Nodes + body.Edges = fileContent.Edges + } + + // Inline nodes override file + if nodesInline != "" { + var nodes []interface{} + if unmarshalErr := json.Unmarshal([]byte(nodesInline), &nodes); unmarshalErr != nil { + return fmt.Errorf("parsing --nodes JSON: %w", unmarshalErr) + } + body.Nodes = nodes + } + + // Inline edges override file + if edgesInline != "" { + var edges []interface{} + if unmarshalErr := json.Unmarshal([]byte(edgesInline), &edges); unmarshalErr != nil { + return fmt.Errorf("parsing --edges JSON: %w", unmarshalErr) + } + body.Edges = edges + } + + client, err := f.HTTPClient() + if err != nil { + return err + } + cfg, err := f.Config() + if err != nil { + return err + } + host := cmdutil.ResolveHost(cmd, cfg) + + bodyBytes, err := json.Marshal(body) + if err != nil { + return err + } + + url := khhttp.BuildBaseURL(host) + "/api/workflows/create" + req, err := client.NewRequest(http.MethodPost, url, bytes.NewReader(bodyBytes)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("HTTP 401: unauthorized, run 'kh auth login' first") + } + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return khhttp.NewAPIError(resp) + } + + var result createResponse + if decodeErr := json.NewDecoder(resp.Body).Decode(&result); decodeErr != nil { + return fmt.Errorf("decoding create response: %w", decodeErr) + } + + p := output.NewPrinter(f.IOStreams, cmd) + return p.PrintData(result, func(tw table.Writer) { + fmt.Fprintf(f.IOStreams.Out, "Created workflow: %s (%s)\n", result.Name, result.ID) + tw.Render() + }) + }, + } + + cmd.Flags().String("name", "", "Workflow name (required)") + cmd.Flags().String("description", "", "Workflow description") + cmd.Flags().String("nodes-file", "", "Path to JSON file with nodes and edges") + cmd.Flags().String("nodes", "", "Inline JSON array of nodes") + cmd.Flags().String("edges", "", "Inline JSON array of edges") + + return cmd +} diff --git a/cmd/workflow/delete.go b/cmd/workflow/delete.go new file mode 100644 index 0000000..4a5fb7c --- /dev/null +++ b/cmd/workflow/delete.go @@ -0,0 +1,92 @@ +package workflow + +import ( + "bufio" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/jedib0t/go-pretty/v6/table" + khhttp "github.com/keeperhub/cli/internal/http" + "github.com/keeperhub/cli/internal/output" + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewDeleteCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a workflow", + Aliases: []string{"rm"}, + Args: cobra.ExactArgs(1), + Example: ` # Delete a workflow (will prompt for confirmation) + kh wf delete abc123 + + # Delete without prompting + kh wf delete abc123 --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + workflowID := args[0] + + yes, err := cmd.Flags().GetBool("yes") + if err != nil { + yes = false + } + + if !yes && f.IOStreams.IsTerminal() { + fmt.Fprintf(f.IOStreams.Out, "Delete workflow %s? This cannot be undone. (y/N) ", workflowID) + scanner := bufio.NewScanner(f.IOStreams.In) + if scanner.Scan() { + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + return cmdutil.CancelError{Err: fmt.Errorf("delete cancelled")} + } + } + } + + client, err := f.HTTPClient() + if err != nil { + return err + } + cfg, err := f.Config() + if err != nil { + return err + } + host := cmdutil.ResolveHost(cmd, cfg) + + url := khhttp.BuildBaseURL(host) + "/api/workflows/" + workflowID + req, err := client.NewRequest(http.MethodDelete, url, nil) + if err != nil { + return err + } + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return cmdutil.NotFoundError{Err: fmt.Errorf("workflow %q not found", workflowID)} + } + if resp.StatusCode != http.StatusOK { + return khhttp.NewAPIError(resp) + } + + var result map[string]interface{} + if decodeErr := json.NewDecoder(resp.Body).Decode(&result); decodeErr != nil { + return fmt.Errorf("decoding delete response: %w", decodeErr) + } + + p := output.NewPrinter(f.IOStreams, cmd) + return p.PrintData(result, func(tw table.Writer) { + fmt.Fprintf(f.IOStreams.Out, "Workflow %s deleted\n", workflowID) + tw.Render() + }) + }, + } + + cmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/cmd/workflow/update.go b/cmd/workflow/update.go new file mode 100644 index 0000000..d5532cb --- /dev/null +++ b/cmd/workflow/update.go @@ -0,0 +1,129 @@ +package workflow + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/jedib0t/go-pretty/v6/table" + khhttp "github.com/keeperhub/cli/internal/http" + "github.com/keeperhub/cli/internal/output" + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +type updateRequest struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Nodes []interface{} `json:"nodes,omitempty"` + Edges []interface{} `json:"edges,omitempty"` +} + +func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a workflow", + Args: cobra.ExactArgs(1), + Example: ` # Update workflow name + kh wf update abc123 --name "New Name" + + # Update nodes from file + kh wf update abc123 --nodes-file workflow.json`, + RunE: func(cmd *cobra.Command, args []string) error { + workflowID := args[0] + + name, err := cmd.Flags().GetString("name") + if err != nil { + return err + } + description, err := cmd.Flags().GetString("description") + if err != nil { + return err + } + nodesFile, err := cmd.Flags().GetString("nodes-file") + if err != nil { + return err + } + + body := updateRequest{} + + if name != "" { + body.Name = name + } + if description != "" { + body.Description = description + } + + if nodesFile != "" { + fileData, readErr := os.ReadFile(nodesFile) + if readErr != nil { + return fmt.Errorf("reading nodes file: %w", readErr) + } + + var fileContent struct { + Nodes []interface{} `json:"nodes"` + Edges []interface{} `json:"edges"` + } + if unmarshalErr := json.Unmarshal(fileData, &fileContent); unmarshalErr != nil { + return fmt.Errorf("parsing nodes file: %w", unmarshalErr) + } + body.Nodes = fileContent.Nodes + body.Edges = fileContent.Edges + } + + client, err := f.HTTPClient() + if err != nil { + return err + } + cfg, err := f.Config() + if err != nil { + return err + } + host := cmdutil.ResolveHost(cmd, cfg) + + bodyBytes, err := json.Marshal(body) + if err != nil { + return err + } + + url := khhttp.BuildBaseURL(host) + "/api/workflows/" + workflowID + req, err := client.NewRequest(http.MethodPatch, url, bytes.NewReader(bodyBytes)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return cmdutil.NotFoundError{Err: fmt.Errorf("workflow %q not found", workflowID)} + } + if resp.StatusCode != http.StatusOK { + return khhttp.NewAPIError(resp) + } + + var result map[string]interface{} + if decodeErr := json.NewDecoder(resp.Body).Decode(&result); decodeErr != nil { + return fmt.Errorf("decoding update response: %w", decodeErr) + } + + p := output.NewPrinter(f.IOStreams, cmd) + return p.PrintData(result, func(tw table.Writer) { + fmt.Fprintf(f.IOStreams.Out, "Workflow %s updated\n", workflowID) + tw.Render() + }) + }, + } + + cmd.Flags().String("name", "", "New workflow name") + cmd.Flags().String("description", "", "New workflow description") + cmd.Flags().String("nodes-file", "", "Path to JSON file with nodes and edges") + + return cmd +} diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go index 95f104b..3d2d175 100644 --- a/cmd/workflow/workflow.go +++ b/cmd/workflow/workflow.go @@ -23,6 +23,9 @@ func NewWorkflowCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(NewListCmd(f)) cmd.AddCommand(NewRunCmd(f)) cmd.AddCommand(NewGetCmd(f)) + cmd.AddCommand(NewCreateCmd(f)) + cmd.AddCommand(NewDeleteCmd(f)) + cmd.AddCommand(NewUpdateCmd(f)) cmd.AddCommand(NewGoLiveCmd(f)) cmd.AddCommand(NewPauseCmd(f)) From ef27c06263cb250cb488cc1061bbe1f627856538 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Sun, 15 Mar 2026 18:04:13 +1100 Subject: [PATCH 2/4] fix: ensure create request always includes nodes and edges arrays The API requires nodes and edges fields. With omitempty, nil slices were omitted from the JSON body, causing 400 errors when creating workflows without --nodes-file. --- cmd/workflow/create.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/workflow/create.go b/cmd/workflow/create.go index f8731df..28cbe12 100644 --- a/cmd/workflow/create.go +++ b/cmd/workflow/create.go @@ -17,8 +17,8 @@ import ( type createRequest struct { Name string `json:"name"` Description string `json:"description,omitempty"` - Nodes []interface{} `json:"nodes,omitempty"` - Edges []interface{} `json:"edges,omitempty"` + Nodes []interface{} `json:"nodes"` + Edges []interface{} `json:"edges"` } type createResponse struct { @@ -73,6 +73,8 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command { body := createRequest{ Name: name, Description: description, + Nodes: []interface{}{}, + Edges: []interface{}{}, } // Load nodes/edges from file if provided From 5fe442270a41e0ba135bca4a7a59e278b60bd735 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Sun, 15 Mar 2026 18:06:49 +1100 Subject: [PATCH 3/4] fix: show clear error when deleting workflow with existing runs Instead of the cryptic "giving up after 4 attempts" from the retryable HTTP client, surface a message explaining the workflow has existing runs that prevent deletion. --- cmd/workflow/delete.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/workflow/delete.go b/cmd/workflow/delete.go index 4a5fb7c..47698c4 100644 --- a/cmd/workflow/delete.go +++ b/cmd/workflow/delete.go @@ -62,13 +62,16 @@ func NewDeleteCmd(f *cmdutil.Factory) *cobra.Command { resp, err := client.Do(req) if err != nil { - return err + return fmt.Errorf("cannot delete workflow %s: it may have existing runs that prevent deletion", workflowID) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return cmdutil.NotFoundError{Err: fmt.Errorf("workflow %q not found", workflowID)} } + if resp.StatusCode == http.StatusInternalServerError { + return fmt.Errorf("cannot delete workflow %s: workflow has existing runs that prevent deletion", workflowID) + } if resp.StatusCode != http.StatusOK { return khhttp.NewAPIError(resp) } From 58e1815633f32210854b033926c2ae4108f64654 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Sun, 15 Mar 2026 18:08:58 +1100 Subject: [PATCH 4/4] test: update subcommand count assertion for new workflow commands --- cmd/workflow/workflow_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/workflow/workflow_test.go b/cmd/workflow/workflow_test.go index 6fd708a..7009b11 100644 --- a/cmd/workflow/workflow_test.go +++ b/cmd/workflow/workflow_test.go @@ -19,11 +19,11 @@ func newTestFactory() *cmdutil.Factory { } } -func TestWorkflowCmdHas5Subcommands(t *testing.T) { +func TestWorkflowCmdHas8Subcommands(t *testing.T) { f := newTestFactory() wfCmd := workflow.NewWorkflowCmd(f) cmds := wfCmd.Commands() - assert.Equal(t, 5, len(cmds), "expected 5 subcommands: list, run, get, go-live, pause") + assert.Equal(t, 8, len(cmds), "expected 8 subcommands: list, run, get, go-live, pause, create, delete, update") } func TestWorkflowCmdHasAlias(t *testing.T) {