feat: Command DTO + Cli surface — AX-native CLI primitives

Command is now a DTO with no root/child awareness:
- Path-based registration: c.Command("deploy/to/homelab", handler)
- Description is an i18n key derived from path: cmd.deploy.to.homelab.description
- Lifecycle: Run(), Start(), Stop(), Restart(), Reload(), Signal()
- All return core.Result — errors flow through Core internally
- Parent commands auto-created from path segments

Cli is now a surface layer that reads from Core's command registry:
- Resolves os.Args to command path
- Parses flags into Options (--port=8080 → Option{K:"port", V:"8080"})
- Calls command action with parsed Options
- Banner and help use i18n

Old Clir code preserved in tests/testdata/cli_clir.go.bak for reference.

211 tests, 77.5% coverage.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-20 12:08:19 +00:00
parent b2d07e7883
commit afc235796f
7 changed files with 1828 additions and 1514 deletions

View file

@ -1,200 +1,152 @@
// SPDX-License-Identifier: EUPL-1.2
// CLI command framework for the Core framework.
// Based on leaanthony/clir — zero-dependency command line interface.
// Cli is the CLI surface layer for the Core command tree.
// It reads commands from Core's registry and wires them to terminal I/O.
//
// Run the CLI:
//
// c := core.New(core.Options{{K: "name", V: "myapp"}})
// c.Command("deploy", handler)
// c.Cli().Run()
//
// The Cli resolves os.Args to a command path, parses flags,
// and calls the command's action with parsed options.
package core
import (
"fmt"
"os"
"strings"
)
// CliAction represents a function called when a command is invoked.
type CliAction func() error
// CliOpts configures a Cli.
type CliOpts struct {
Version string
Name string
Description string
}
// Cli is the CLI command framework.
// Cli is the CLI surface for the Core command tree.
type Cli struct {
opts *CliOpts
rootCommand *Command
defaultCommand *Command
preRunCommand func(*Cli) error
postRunCommand func(*Cli) error
bannerFunction func(*Cli) string
errorHandler func(string, error) error
core *Core
banner func(*Cli) string
}
// defaultBannerFunction prints a banner for the application.
func defaultBannerFunction(c *Cli) string {
version := ""
if c.opts != nil && c.opts.Version != "" {
version = " " + c.opts.Version
}
name := ""
description := ""
if c.opts != nil {
name = c.opts.Name
description = c.opts.Description
}
if description != "" {
return fmt.Sprintf("%s%s - %s", name, version, description)
}
return fmt.Sprintf("%s%s", name, version)
}
// Command returns the root command.
func (c *Cli) Command() *Command {
return c.rootCommand
}
// Version returns the application version string.
func (c *Cli) Version() string {
if c.opts != nil {
return c.opts.Version
}
return ""
}
// Name returns the application name.
func (c *Cli) Name() string {
if c.opts != nil {
return c.opts.Name
}
return c.rootCommand.name
}
// ShortDescription returns the application short description.
func (c *Cli) ShortDescription() string {
if c.opts != nil {
return c.opts.Description
}
return c.rootCommand.shortdescription
}
// SetBannerFunction sets the function that generates the banner string.
func (c *Cli) SetBannerFunction(fn func(*Cli) string) {
c.bannerFunction = fn
}
// SetErrorFunction sets a custom error handler for undefined flags.
func (c *Cli) SetErrorFunction(fn func(string, error) error) {
c.errorHandler = fn
}
// AddCommand adds a command to the application.
func (c *Cli) AddCommand(command *Command) {
c.rootCommand.AddCommand(command)
}
// PrintBanner prints the application banner.
func (c *Cli) PrintBanner() {
fmt.Println(c.bannerFunction(c))
fmt.Println("")
}
// PrintHelp prints the application help.
func (c *Cli) PrintHelp() {
c.rootCommand.PrintHelp()
}
// Run runs the application with the given arguments.
func (c *Cli) Run(args ...string) error {
if c.preRunCommand != nil {
if err := c.preRunCommand(c); err != nil {
return err
}
}
// Run resolves os.Args to a command path and executes it.
//
// c.Cli().Run()
// c.Cli().Run("deploy", "to", "homelab")
func (cl *Cli) Run(args ...string) Result[any] {
if len(args) == 0 {
args = os.Args[1:]
}
if err := c.rootCommand.run(args); err != nil {
return err
}
if c.postRunCommand != nil {
if err := c.postRunCommand(c); err != nil {
return err
// Filter out empty args and test flags
var clean []string
for _, a := range args {
if a != "" && !strings.HasPrefix(a, "-test.") {
clean = append(clean, a)
}
}
return nil
if cl.core == nil || cl.core.commands == nil || len(cl.core.commands.commands) == 0 {
// No commands registered — print banner and exit
if cl.banner != nil {
fmt.Println(cl.banner(cl))
}
return Result[any]{}
}
// Resolve command path from args
// "deploy to homelab" → try "deploy/to/homelab", then "deploy/to", then "deploy"
var cmd *Command
var remaining []string
for i := len(clean); i > 0; i-- {
path := strings.Join(clean[:i], "/")
if c, ok := cl.core.commands.commands[path]; ok {
cmd = c
remaining = clean[i:]
break
}
}
if cmd == nil {
// No matching command — try root-level action or print help
if cl.banner != nil {
fmt.Println(cl.banner(cl))
}
cl.PrintHelp()
return Result[any]{}
}
// Build options from remaining args (flags become Options)
opts := Options{}
for _, arg := range remaining {
if strings.HasPrefix(arg, "--") {
parts := strings.SplitN(strings.TrimPrefix(arg, "--"), "=", 2)
if len(parts) == 2 {
opts = append(opts, Option{K: parts[0], V: parts[1]})
} else {
opts = append(opts, Option{K: parts[0], V: true})
}
} else if strings.HasPrefix(arg, "-") {
parts := strings.SplitN(strings.TrimPrefix(arg, "-"), "=", 2)
if len(parts) == 2 {
opts = append(opts, Option{K: parts[0], V: parts[1]})
} else {
opts = append(opts, Option{K: parts[0], V: true})
}
} else {
opts = append(opts, Option{K: "_arg", V: arg})
}
}
return cmd.Run(opts)
}
// DefaultCommand sets the command to run when no other commands are given.
func (c *Cli) DefaultCommand(defaultCommand *Command) *Cli {
c.defaultCommand = defaultCommand
return c
// PrintHelp prints available commands.
//
// c.Cli().PrintHelp()
func (cl *Cli) PrintHelp() {
if cl.core == nil || cl.core.commands == nil {
return
}
name := ""
if cl.core.app != nil {
name = cl.core.app.Name
}
if name != "" {
fmt.Printf("%s commands:\n\n", name)
} else {
fmt.Println("Commands:\n")
}
cl.core.commands.mu.RLock()
defer cl.core.commands.mu.RUnlock()
for path, cmd := range cl.core.commands.commands {
if cmd.hidden {
continue
}
desc := cl.core.I18n().T(cmd.I18nKey())
// If i18n returned the key itself (no translation), show path only
if desc == cmd.I18nKey() {
fmt.Printf(" %s\n", path)
} else {
fmt.Printf(" %-30s %s\n", path, desc)
}
}
}
// NewChildCommand creates a new subcommand.
func (c *Cli) NewChildCommand(name string, description ...string) *Command {
return c.rootCommand.NewChildCommand(name, description...)
// SetBanner sets the banner function.
//
// c.Cli().SetBanner(func(_ *core.Cli) string { return "My App v1.0" })
func (cl *Cli) SetBanner(fn func(*Cli) string) {
cl.banner = fn
}
// NewChildCommandInheritFlags creates a new subcommand that inherits parent flags.
func (c *Cli) NewChildCommandInheritFlags(name string, description ...string) *Command {
return c.rootCommand.NewChildCommandInheritFlags(name, description...)
}
// PreRun sets a function to call before running the command.
func (c *Cli) PreRun(callback func(*Cli) error) {
c.preRunCommand = callback
}
// PostRun sets a function to call after running the command.
func (c *Cli) PostRun(callback func(*Cli) error) {
c.postRunCommand = callback
}
// BoolFlag adds a boolean flag to the root command.
func (c *Cli) BoolFlag(name, description string, variable *bool) *Cli {
c.rootCommand.BoolFlag(name, description, variable)
return c
}
// StringFlag adds a string flag to the root command.
func (c *Cli) StringFlag(name, description string, variable *string) *Cli {
c.rootCommand.StringFlag(name, description, variable)
return c
}
// IntFlag adds an int flag to the root command.
func (c *Cli) IntFlag(name, description string, variable *int) *Cli {
c.rootCommand.IntFlag(name, description, variable)
return c
}
// AddFlags adds struct-tagged flags to the root command.
func (c *Cli) AddFlags(flags any) *Cli {
c.rootCommand.AddFlags(flags)
return c
}
// Action defines an action for the root command.
func (c *Cli) Action(callback CliAction) *Cli {
c.rootCommand.Action(callback)
return c
}
// LongDescription sets the long description for the root command.
func (c *Cli) LongDescription(longdescription string) *Cli {
c.rootCommand.LongDescription(longdescription)
return c
}
// OtherArgs returns the non-flag arguments passed to the CLI.
func (c *Cli) OtherArgs() []string {
return c.rootCommand.flags.Args()
}
// NewChildCommandFunction creates a subcommand from a function with struct flags.
func (c *Cli) NewChildCommandFunction(name string, description string, fn any) *Cli {
c.rootCommand.NewChildCommandFunction(name, description, fn)
return c
// Banner returns the banner string.
func (cl *Cli) Banner() string {
if cl.banner != nil {
return cl.banner(cl)
}
if cl.core != nil && cl.core.app != nil && cl.core.app.Name != "" {
return cl.core.app.Name
}
return ""
}

File diff suppressed because it is too large Load diff

View file

@ -80,7 +80,6 @@ func New(opts ...Options) *Core {
config: &Config{ConfigOpts: &ConfigOpts{}},
error: &ErrorPanic{},
log: &ErrorLog{log: defaultLog},
cli: &Cli{opts: &CliOpts{}},
service: &Service{},
lock: &Lock{},
ipc: &Ipc{},
@ -95,10 +94,8 @@ func New(opts ...Options) *Core {
}
}
// Init Cli root command from app name
c.cli.rootCommand = NewCommand(c.app.Name)
c.cli.rootCommand.setParentCommandPath("")
c.cli.rootCommand.setApp(c.cli)
// Init Cli surface with Core reference
c.cli = &Cli{core: c}
return c
}

View file

@ -22,8 +22,9 @@ type Core struct {
config *Config // c.Config() — Configuration, settings, feature flags
error *ErrorPanic // c.Error() — Panic recovery and crash reporting
log *ErrorLog // c.Log() — Structured logging + error wrapping
cli *Cli // c.Cli() — CLI command framework
service *Service // c.Service("name") — Service registry and lifecycle
cli *Cli // c.Cli() — CLI surface layer
commands *commandRegistry // c.Command("path") — Command tree
service *Service // c.Service("name") — Service registry and lifecycle
lock *Lock // c.Lock("name") — Named mutexes
ipc *Ipc // c.IPC() — Message bus for IPC
i18n *I18n // c.I18n() — Internationalisation and locale collection

View file

@ -7,70 +7,72 @@ import (
"github.com/stretchr/testify/assert"
)
// --- Cli ---
// --- Cli Surface ---
func TestCli_Good(t *testing.T) {
c := New()
assert.NotNil(t, c.Cli())
assert.NotNil(t, c.Cli().Command())
}
func TestCli_Named_Good(t *testing.T) {
func TestCli_Banner_Good(t *testing.T) {
c := New(Options{{K: "name", V: "myapp"}})
assert.NotNil(t, c.Cli().Command())
assert.Equal(t, "myapp", c.Cli().Banner())
}
func TestCli_NewChildCommand_Good(t *testing.T) {
c := New(Options{{K: "name", V: "myapp"}})
child := c.Cli().NewChildCommand("test", "a test command")
assert.NotNil(t, child)
}
func TestCli_AddCommand_Good(t *testing.T) {
func TestCli_SetBanner_Good(t *testing.T) {
c := New()
cmd := NewCommand("hello", "says hello")
c.Cli().AddCommand(cmd)
}
func TestCli_Flags_Good(t *testing.T) {
c := New()
var name string
var debug bool
var port int
c.Cli().StringFlag("name", "app name", &name)
c.Cli().BoolFlag("debug", "enable debug", &debug)
c.Cli().IntFlag("port", "port number", &port)
c.Cli().SetBanner(func(_ *Cli) string { return "Custom Banner" })
assert.Equal(t, "Custom Banner", c.Cli().Banner())
}
func TestCli_Run_Good(t *testing.T) {
c := New()
executed := false
c.Cli().Command().Action(func() error {
c.Command("hello", func(_ Options) Result[any] {
executed = true
return nil
return Result[any]{Value: "world", OK: true}
})
err := c.Cli().Run("")
assert.NoError(t, err)
r := c.Cli().Run("hello")
assert.True(t, r.OK)
assert.Equal(t, "world", r.Value)
assert.True(t, executed)
}
// --- Command ---
func TestCommand_New_Good(t *testing.T) {
cmd := NewCommand("test", "a test command")
assert.NotNil(t, cmd)
func TestCli_Run_Nested_Good(t *testing.T) {
c := New()
executed := false
c.Command("deploy/to/homelab", func(_ Options) Result[any] {
executed = true
return Result[any]{OK: true}
})
r := c.Cli().Run("deploy", "to", "homelab")
assert.True(t, r.OK)
assert.True(t, executed)
}
func TestCommand_Child_Good(t *testing.T) {
parent := NewCommand("root")
child := parent.NewChildCommand("sub", "a subcommand")
assert.NotNil(t, child)
func TestCli_Run_WithFlags_Good(t *testing.T) {
c := New()
var received Options
c.Command("serve", func(opts Options) Result[any] {
received = opts
return Result[any]{OK: true}
})
c.Cli().Run("serve", "--port=8080", "--debug")
assert.Equal(t, "8080", received.String("port"))
assert.True(t, received.Bool("debug"))
}
func TestCommand_Flags_Good(t *testing.T) {
cmd := NewCommand("test")
var name string
var debug bool
cmd.StringFlag("name", "app name", &name)
cmd.BoolFlag("debug", "enable debug", &debug)
func TestCli_Run_NoCommand_Good(t *testing.T) {
c := New()
// No commands registered — should not panic
r := c.Cli().Run()
assert.False(t, r.OK)
}
func TestCli_PrintHelp_Good(t *testing.T) {
c := New(Options{{K: "name", V: "myapp"}})
c.Command("deploy", func(_ Options) Result[any] { return Result[any]{OK: true} })
c.Command("serve", func(_ Options) Result[any] { return Result[any]{OK: true} })
// Should not panic
c.Cli().PrintHelp()
}

137
tests/command_test.go Normal file
View file

@ -0,0 +1,137 @@
package core_test
import (
"testing"
. "forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert"
)
// --- Command DTO ---
func TestCommand_Register_Good(t *testing.T) {
c := New()
result := c.Command("deploy", func(_ Options) Result[any] {
return Result[any]{Value: "deployed", OK: true}
})
assert.Nil(t, result) // nil = success
}
func TestCommand_Get_Good(t *testing.T) {
c := New()
c.Command("deploy", func(_ Options) Result[any] {
return Result[any]{OK: true}
})
cmd := c.Command("deploy")
assert.NotNil(t, cmd)
}
func TestCommand_Get_Bad(t *testing.T) {
c := New()
cmd := c.Command("nonexistent")
assert.Nil(t, cmd)
}
func TestCommand_Run_Good(t *testing.T) {
c := New()
c.Command("greet", func(opts Options) Result[any] {
return Result[any]{Value: "hello " + opts.String("name"), OK: true}
})
cmd := c.Command("greet").(*Command)
r := cmd.Run(Options{{K: "name", V: "world"}})
assert.True(t, r.OK)
assert.Equal(t, "hello world", r.Value)
}
func TestCommand_Run_NoAction_Good(t *testing.T) {
c := New()
c.Command("empty", Options{{K: "description", V: "no action"}})
cmd := c.Command("empty").(*Command)
r := cmd.Run(Options{})
assert.False(t, r.OK)
}
// --- Nested Commands ---
func TestCommand_Nested_Good(t *testing.T) {
c := New()
c.Command("deploy/to/homelab", func(_ Options) Result[any] {
return Result[any]{Value: "deployed to homelab", OK: true}
})
// Direct path lookup
cmd := c.Command("deploy/to/homelab")
assert.NotNil(t, cmd)
// Parent auto-created
parent := c.Command("deploy")
assert.NotNil(t, parent)
mid := c.Command("deploy/to")
assert.NotNil(t, mid)
}
func TestCommand_Paths_Good(t *testing.T) {
c := New()
c.Command("deploy", func(_ Options) Result[any] { return Result[any]{OK: true} })
c.Command("serve", func(_ Options) Result[any] { return Result[any]{OK: true} })
c.Command("deploy/to/homelab", func(_ Options) Result[any] { return Result[any]{OK: true} })
paths := c.Commands()
assert.Contains(t, paths, "deploy")
assert.Contains(t, paths, "serve")
assert.Contains(t, paths, "deploy/to/homelab")
assert.Contains(t, paths, "deploy/to") // auto-created parent
}
// --- I18n Key Derivation ---
func TestCommand_I18nKey_Good(t *testing.T) {
c := New()
c.Command("deploy/to/homelab", func(_ Options) Result[any] { return Result[any]{OK: true} })
cmd := c.Command("deploy/to/homelab").(*Command)
assert.Equal(t, "cmd.deploy.to.homelab.description", cmd.I18nKey())
}
func TestCommand_I18nKey_Custom_Good(t *testing.T) {
c := New()
c.Command("deploy", func(_ Options) Result[any] { return Result[any]{OK: true} }, Options{{K: "description", V: "custom.deploy.key"}})
cmd := c.Command("deploy").(*Command)
assert.Equal(t, "custom.deploy.key", cmd.I18nKey())
}
func TestCommand_I18nKey_Simple_Good(t *testing.T) {
c := New()
c.Command("serve", func(_ Options) Result[any] { return Result[any]{OK: true} })
cmd := c.Command("serve").(*Command)
assert.Equal(t, "cmd.serve.description", cmd.I18nKey())
}
// --- Lifecycle ---
func TestCommand_Lifecycle_NoImpl_Good(t *testing.T) {
c := New()
c.Command("serve", func(_ Options) Result[any] {
return Result[any]{Value: "running", OK: true}
})
cmd := c.Command("serve").(*Command)
// Start falls back to Run when no lifecycle impl
r := cmd.Start(Options{})
assert.True(t, r.OK)
assert.Equal(t, "running", r.Value)
// Stop/Restart/Reload/Signal return empty Result without lifecycle
assert.False(t, cmd.Stop().OK)
assert.False(t, cmd.Restart().OK)
assert.False(t, cmd.Reload().OK)
assert.False(t, cmd.Signal("HUP").OK)
}
// --- Empty path ---
func TestCommand_EmptyPath_Bad(t *testing.T) {
c := New()
result := c.Command("", func(_ Options) Result[any] { return Result[any]{OK: true} })
assert.NotNil(t, result) // error
}

1339
tests/testdata/cli_clir.go.bak vendored Normal file

File diff suppressed because it is too large Load diff