Skip to content

Commit 2a53e32

Browse files
Copilotintel352
andauthored
Add CLI workflow handler and convert wfctl to a workflow config embedded app with full pipeline primitives (#277)
* Initial plan * feat: add CLI workflow handler and convert wfctl to embedded config app - Add handlers/cli.go: CLIWorkflowHandler for 'cli' workflow type with command registration, config-driven dispatch, and usage generation - Add handlers/cli_test.go: comprehensive tests for CLIWorkflowHandler - Add config.LoadFromBytes helper for loading configs from embedded bytes - Register CLIWorkflowHandler in setup.DefaultHandlers() - Add cmd/wfctl/wfctl.yaml: embedded CLI config declaring all 24 commands - Rewrite cmd/wfctl/main.go to load embedded config and use CLIWorkflowHandler for dispatch, removing hardcoded usage() and command routing Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * Convert CLI commands to workflow primitives: CLITrigger, step.cli_invoke, step.cli_print Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * Apply code review feedback: ctx threading, validation, no global slog, engine logger propagation Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> Co-authored-by: Jonathan Langevin <codingsloth@pm.me>
1 parent 0563801 commit 2a53e32

18 files changed

Lines changed: 2106 additions & 57 deletions

cmd/wfctl/main.go

Lines changed: 104 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,36 @@
11
package main
22

33
import (
4+
"context"
5+
_ "embed"
46
"fmt"
7+
"io"
8+
"log/slog"
59
"os"
10+
"os/signal"
11+
"syscall"
612
"time"
13+
14+
workflow "github.com/GoCodeAlone/workflow"
15+
"github.com/GoCodeAlone/workflow/config"
16+
"github.com/GoCodeAlone/workflow/handlers"
17+
"github.com/GoCodeAlone/workflow/module"
718
)
819

20+
// wfctlConfigBytes is the embedded workflow config that declares wfctl's CLI
21+
// structure and maps every command to a pipeline triggered via the "cli"
22+
// trigger type. The engine resolves these pipelines at startup so each command
23+
// flows through the workflow engine as a proper workflow primitive.
24+
//
25+
//go:embed wfctl.yaml
26+
var wfctlConfigBytes []byte
27+
928
var version = "dev"
1029

30+
// commands maps each CLI command name to its Go implementation. The command
31+
// metadata (name, description) is declared in wfctl.yaml; this map provides
32+
// the runtime functions that are registered in the CLICommandRegistry service
33+
// and invoked by step.cli_invoke from within each command's pipeline.
1134
var commands = map[string]func([]string) error{
1235
"init": runInit,
1336
"validate": runValidate,
@@ -35,65 +58,83 @@ var commands = map[string]func([]string) error{
3558
"mcp": runMCP,
3659
}
3760

38-
func usage() {
39-
fmt.Fprintf(os.Stderr, `wfctl - Workflow Engine CLI (version %s)
40-
41-
Usage:
42-
wfctl <command> [options]
43-
44-
Commands:
45-
init Scaffold a new workflow project from a template
46-
validate Validate a workflow configuration file
47-
inspect Inspect modules, workflows, and triggers in a config
48-
run Run a workflow engine from a config file
49-
plugin Plugin management (init, docs, search, install, list, update, remove)
50-
pipeline Pipeline management (list, run)
51-
schema Generate JSON Schema for workflow configs
52-
snippets Export IDE snippets (--format vscode|jetbrains|json)
53-
manifest Analyze config and report infrastructure requirements
54-
migrate Manage database schema migrations
55-
build-ui Build the application UI (npm install + npm run build + validate)
56-
ui UI tooling (scaffold: generate Vite+React+TypeScript SPA from OpenAPI spec)
57-
publish Prepare and publish a plugin manifest to the workflow-registry
58-
deploy Deploy the workflow application (docker, kubernetes, cloud)
59-
api API tooling (extract: generate OpenAPI 3.0 spec from config)
60-
diff Compare two workflow config files and show what changed
61-
template Template management (validate: check templates against known types)
62-
contract Contract testing (test: generate/compare API contracts)
63-
compat Compatibility checking (check: verify config works with current engine)
64-
generate Code generation (github-actions: generate CI/CD workflows from config)
65-
git Git integration (connect: link to GitHub repo, push: commit and push)
66-
registry Registry management (list, add, remove plugin registry sources)
67-
update Update wfctl to the latest version (use --check to only check)
68-
mcp Start the MCP server over stdio for AI assistant integration
69-
70-
Run 'wfctl <command> -h' for command-specific help.
71-
`, version)
72-
}
73-
7461
func main() {
75-
if len(os.Args) < 2 {
76-
usage()
62+
// Load the embedded config. All command definitions and pipeline wiring
63+
// live in wfctl.yaml — no hardcoded routing in this file.
64+
cfg, err := config.LoadFromBytes(wfctlConfigBytes)
65+
if err != nil {
66+
fmt.Fprintf(os.Stderr, "internal error: failed to load embedded config: %v\n", err) //nolint:gosec // G705
7767
os.Exit(1)
7868
}
7969

80-
cmd := os.Args[1]
81-
if cmd == "-h" || cmd == "--help" || cmd == "help" {
82-
usage()
83-
os.Exit(0)
70+
// Inject the build-time version into the cli workflow config map so that
71+
// --version and the usage header display the correct release string.
72+
if wfCfg, ok := cfg.Workflows["cli"].(map[string]any); ok {
73+
wfCfg["version"] = version
8474
}
85-
if cmd == "-v" || cmd == "--version" || cmd == "version" {
86-
fmt.Println(version)
87-
os.Exit(0)
75+
76+
// Build the engine with all default handlers and triggers.
77+
// The discard logger is propagated to all cmd-* pipelines automatically
78+
// via configurePipelines, so internal plumbing logs do not appear in the
79+
// terminal. Each command creates its own logger when it needs output.
80+
engineLogger := slog.New(slog.NewTextHandler(io.Discard, nil))
81+
engineInst, err := workflow.NewEngineBuilder().
82+
WithLogger(engineLogger).
83+
WithAllDefaults().
84+
Build()
85+
if err != nil {
86+
fmt.Fprintf(os.Stderr, "internal error: failed to build engine: %v\n", err) //nolint:gosec // G705
87+
os.Exit(1)
88+
}
89+
90+
// Register all Go command implementations in the CLICommandRegistry service
91+
// before BuildFromConfig so that step.cli_invoke can look them up at
92+
// pipeline execution time (service is resolved lazily on each Execute call).
93+
registry := module.NewCLICommandRegistry()
94+
for name, fn := range commands {
95+
registry.Register(name, module.CLICommandFunc(fn))
8896
}
97+
if err := engineInst.App().RegisterService(module.CLICommandRegistryServiceName, registry); err != nil {
98+
fmt.Fprintf(os.Stderr, "internal error: failed to register command registry: %v\n", err) //nolint:gosec // G705
99+
os.Exit(1)
100+
}
101+
102+
// Register the CLI-specific step types on the engine's step registry.
103+
// step.cli_invoke calls a Go function by name from CLICommandRegistry.
104+
// step.cli_print writes a template-resolved message to stdout/stderr.
105+
// These are registered here rather than via the pipelinesteps plugin to
106+
// keep wfctl lean — only what the binary actually needs is loaded.
107+
engineInst.AddStepType("step.cli_invoke", module.NewCLIInvokeStepFactory())
108+
engineInst.AddStepType("step.cli_print", module.NewCLIPrintStepFactory())
89109

90-
fn, ok := commands[cmd]
91-
if !ok {
92-
fmt.Fprintf(os.Stderr, "unknown command: %s\n\n", cmd) //nolint:gosec // G705: CLI error output
93-
usage()
110+
// BuildFromConfig wires the engine from wfctl.yaml:
111+
// 1. CLIWorkflowHandler is configured from workflows.cli (registers itself
112+
// as "cliWorkflowHandler" in the app service registry).
113+
// 2. Each cmd-* pipeline is created and registered.
114+
// 3. CLITrigger is configured once per pipeline (via the "cli" inline
115+
// trigger), accumulating command→pipeline mappings.
116+
if err := engineInst.BuildFromConfig(cfg); err != nil {
117+
fmt.Fprintf(os.Stderr, "internal error: failed to configure engine: %v\n", err) //nolint:gosec // G705
94118
os.Exit(1)
95119
}
96120

121+
// Retrieve the CLIWorkflowHandler that registered itself during BuildFromConfig.
122+
var cliHandler *handlers.CLIWorkflowHandler
123+
if err := engineInst.App().GetService(handlers.CLIWorkflowHandlerServiceName, &cliHandler); err != nil || cliHandler == nil {
124+
fmt.Fprintf(os.Stderr, "internal error: CLIWorkflowHandler not found in service registry\n") //nolint:gosec // G705
125+
os.Exit(1)
126+
}
127+
// Error/usage output goes to stderr; command output goes to stdout.
128+
cliHandler.SetOutput(os.Stderr)
129+
130+
if len(os.Args) < 2 {
131+
// No subcommand — print usage and exit non-zero.
132+
_ = cliHandler.Dispatch([]string{"-h"})
133+
os.Exit(1)
134+
}
135+
136+
cmd := os.Args[1]
137+
97138
// Start the update check in the background before running the command so
98139
// that it runs concurrently. For long-running commands (mcp, run) we skip
99140
// it entirely. After the command finishes we wait briefly for the result.
@@ -102,8 +143,20 @@ func main() {
102143
updateNoticeDone = checkForUpdateNotice()
103144
}
104145

105-
if err := fn(os.Args[2:]); err != nil {
106-
fmt.Fprintf(os.Stderr, "error: %v\n", err) //nolint:gosec // G705: CLI error output
146+
// Set up a context that is cancelled on SIGINT/SIGTERM so that long-running
147+
// commands (e.g. wfctl mcp, wfctl run) can be interrupted cleanly.
148+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
149+
150+
dispatchErr := cliHandler.DispatchContext(ctx, os.Args[1:])
151+
// Release signal resources before waiting for the update notice or exiting.
152+
stop()
153+
154+
if dispatchErr != nil {
155+
// The handler already printed routing errors (unknown/missing command).
156+
// Only emit the "error:" prefix for actual command execution failures.
157+
if _, isKnown := commands[cmd]; isKnown {
158+
fmt.Fprintf(os.Stderr, "error: %v\n", dispatchErr) //nolint:gosec // G705
159+
}
107160
os.Exit(1)
108161
}
109162

cmd/wfctl/type_registry.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,16 @@ func KnownStepTypes() map[string]StepTypeInfo {
552552
Plugin: "pipelinesteps",
553553
ConfigKeys: []string{"message", "level"},
554554
},
555+
"step.cli_print": {
556+
Type: "step.cli_print",
557+
Plugin: "pipelinesteps",
558+
ConfigKeys: []string{"message", "newline", "target"},
559+
},
560+
"step.cli_invoke": {
561+
Type: "step.cli_invoke",
562+
Plugin: "pipelinesteps",
563+
ConfigKeys: []string{"command"},
564+
},
555565
"step.delegate": {
556566
Type: "step.delegate",
557567
Plugin: "pipelinesteps",

0 commit comments

Comments
 (0)