Skip to content
Merged

Dev #13

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c45b228
feat: restore functional option pattern for New()
Snider Mar 24, 2026
9f6caa3
Merge pull request '[agent/codex] Review PR #28. Read CLAUDE.md first…
Mar 24, 2026
2d01798
fix: address Codex review findings on PR #28
Snider Mar 24, 2026
9b5f6df
fix: prevent double IPC registration + empty service placeholder
Snider Mar 24, 2026
64e6a26
fix: move HandleIPCEvents discovery to New() post-construction
Snider Mar 24, 2026
74f78c8
feat: RegisterService with instance storage + interface discovery
Snider Mar 24, 2026
a49bc46
feat: Options struct + Result methods + WithOption convenience
Snider Mar 24, 2026
2a81b4f
feat: App struct with New(Options) + Find() as method
Snider Mar 24, 2026
85faedf
fix: update Cli doc comment + tests for new Options contract
Snider Mar 24, 2026
f69be96
feat: Cli.New(c) constructor — Core uses it during construction
Snider Mar 24, 2026
198ab83
wip: checkpoint before v0.3.3 parity rewrite
Snider Mar 24, 2026
177f73c
feat: WithService with v0.3.3 name discovery + IPC handler auto-regis…
Snider Mar 24, 2026
b03c1a3
feat: WithService with v0.3.3 name discovery + IPC handler auto-regis…
Snider Mar 24, 2026
001e90e
feat: WithName for explicit service naming
Snider Mar 24, 2026
d1579f6
test: lifecycle + HandleIPCEvents end-to-end via WithService
Snider Mar 24, 2026
05d0a64
fix: WithServiceLock enables, New() applies after all opts — v0.3.3 p…
Snider Mar 24, 2026
2303c27
feat: MustServiceFor[T] + fix service names test for auto-registered cli
Snider Mar 24, 2026
ae48254
wip: v0.3.3 parity — Tasks 1-7 complete, data/embed tests need fixing
Snider Mar 24, 2026
94e1f40
fix: Result.New handles (value, error) pairs correctly + embed test f…
Snider Mar 24, 2026
9c5cc6e
feat: New() constructors for Config, Fs + simplify contract.go init
Snider Mar 24, 2026
7f4c434
fix: Service() returns instance, ServiceFor uses type assertion directly
Snider Mar 24, 2026
7608808
feat: Core.Run() — ServiceStartup → Cli → ServiceShutdown lifecycle
Snider Mar 24, 2026
af1cee2
feat: Core.Run() handles os.Exit on error
Snider Mar 24, 2026
5362a99
feat: New() returns *Core directly — no Result wrapper needed
Snider Mar 24, 2026
f72c578
Merge pull request 'feat: restore functional option pattern for New()…
Mar 24, 2026
95076be
fix: shutdown context, double IPC registration
Snider Mar 24, 2026
5855a61
Merge pull request 'fix: shutdown context + double IPC registration' …
Mar 24, 2026
d982193
test: add _Bad/_Ugly tests + fix per-Core lock isolation
Snider Mar 24, 2026
f6ed40d
Merge pull request 'test: _Bad/_Ugly tests + per-Core lock isolation'…
Mar 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 33 additions & 19 deletions app.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: EUPL-1.2

// Application identity for the Core framework.
// Based on leaanthony/sail — Name, Filename, Path.

package core

Expand All @@ -11,32 +10,47 @@ import (
)

// App holds the application identity and optional GUI runtime.
//
// app := core.App{}.New(core.NewOptions(
// core.Option{Key: "name", Value: "Core CLI"},
// core.Option{Key: "version", Value: "1.0.0"},
// ))
type App struct {
// Name is the human-readable application name (e.g., "Core CLI").
Name string

// Version is the application version string (e.g., "1.2.3").
Version string

// Description is a short description of the application.
Name string
Version string
Description string
Filename string
Path string
Runtime any // GUI runtime (e.g., Wails App). Nil for CLI-only.
}

// Filename is the executable filename (e.g., "core").
Filename string

// Path is the absolute path to the executable.
Path string

// Runtime is the GUI runtime (e.g., Wails App).
// Nil for CLI-only applications.
Runtime any
// New creates an App from Options.
//
// app := core.App{}.New(core.NewOptions(
// core.Option{Key: "name", Value: "myapp"},
// core.Option{Key: "version", Value: "1.0.0"},
// ))
func (a App) New(opts Options) App {
if name := opts.String("name"); name != "" {
a.Name = name
}
if version := opts.String("version"); version != "" {
a.Version = version
}
if desc := opts.String("description"); desc != "" {
a.Description = desc
}
if filename := opts.String("filename"); filename != "" {
a.Filename = filename
}
return a
}

// Find locates a program on PATH and returns a Result containing the App.
//
// r := core.Find("node", "Node.js")
// r := core.App{}.Find("node", "Node.js")
// if r.OK { app := r.Value.(*App) }
func Find(filename, name string) Result {
func (a App) Find(filename, name string) Result {
path, err := exec.LookPath(filename)
if err != nil {
return Result{err, false}
Expand Down
41 changes: 35 additions & 6 deletions app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,41 @@ import (
"github.com/stretchr/testify/assert"
)

// --- App ---
// --- App.New ---

func TestApp_Good(t *testing.T) {
c := New(Options{{Key: "name", Value: "myapp"}})
func TestApp_New_Good(t *testing.T) {
app := App{}.New(NewOptions(
Option{Key: "name", Value: "myapp"},
Option{Key: "version", Value: "1.0.0"},
Option{Key: "description", Value: "test app"},
))
assert.Equal(t, "myapp", app.Name)
assert.Equal(t, "1.0.0", app.Version)
assert.Equal(t, "test app", app.Description)
}

func TestApp_New_Empty_Good(t *testing.T) {
app := App{}.New(NewOptions())
assert.Equal(t, "", app.Name)
assert.Equal(t, "", app.Version)
}

func TestApp_New_Partial_Good(t *testing.T) {
app := App{}.New(NewOptions(
Option{Key: "name", Value: "myapp"},
))
assert.Equal(t, "myapp", app.Name)
assert.Equal(t, "", app.Version)
}

// --- App via Core ---

func TestApp_Core_Good(t *testing.T) {
c := New(WithOption("name", "myapp"))
assert.Equal(t, "myapp", c.App().Name)
}

func TestApp_Empty_Good(t *testing.T) {
func TestApp_Core_Empty_Good(t *testing.T) {
c := New()
assert.NotNil(t, c.App())
assert.Equal(t, "", c.App().Name)
Expand All @@ -26,14 +53,16 @@ func TestApp_Runtime_Good(t *testing.T) {
assert.NotNil(t, c.App().Runtime)
}

// --- App.Find ---

func TestApp_Find_Good(t *testing.T) {
r := Find("go", "go")
r := App{}.Find("go", "go")
assert.True(t, r.OK)
app := r.Value.(*App)
assert.NotEmpty(t, app.Path)
}

func TestApp_Find_Bad(t *testing.T) {
r := Find("nonexistent-binary-xyz", "test")
r := App{}.Find("nonexistent-binary-xyz", "test")
assert.False(t, r.OK)
}
69 changes: 39 additions & 30 deletions cli.go
Original file line number Diff line number Diff line change
@@ -1,30 +1,36 @@
// SPDX-License-Identifier: EUPL-1.2

// 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{{Key: "name", Value: "myapp"}})
// c.Command("deploy", handler)
// c := core.New(core.WithOption("name", "myapp")).Value.(*Core)
// c.Command("deploy", core.Command{Action: 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 (
"io"
"os"
)

// CliOptions holds configuration for the Cli service.
type CliOptions struct{}

// Cli is the CLI surface for the Core command tree.
type Cli struct {
core *Core
*ServiceRuntime[CliOptions]
output io.Writer
banner func(*Cli) string
}

// Register creates a Cli service factory for core.WithService.
//
// core.New(core.WithService(core.CliRegister))
func CliRegister(c *Core) Result {
cl := &Cli{output: os.Stdout}
cl.ServiceRuntime = NewServiceRuntime[CliOptions](c, CliOptions{})
return c.RegisterService("cli", cl)
}

// Print writes to the CLI output (defaults to os.Stdout).
//
// c.Cli().Print("hello %s", "world")
Expand All @@ -49,17 +55,18 @@ func (cl *Cli) Run(args ...string) Result {
}

clean := FilterArgs(args)
c := cl.Core()

if cl.core == nil || cl.core.commands == nil {
if c == nil || c.commands == nil {
if cl.banner != nil {
cl.Print(cl.banner(cl))
}
return Result{}
}

cl.core.commands.mu.RLock()
cmdCount := len(cl.core.commands.commands)
cl.core.commands.mu.RUnlock()
c.commands.mu.RLock()
cmdCount := len(c.commands.commands)
c.commands.mu.RUnlock()

if cmdCount == 0 {
if cl.banner != nil {
Expand All @@ -72,16 +79,16 @@ func (cl *Cli) Run(args ...string) Result {
var cmd *Command
var remaining []string

cl.core.commands.mu.RLock()
c.commands.mu.RLock()
for i := len(clean); i > 0; i-- {
path := JoinPath(clean[:i]...)
if c, ok := cl.core.commands.commands[path]; ok {
cmd = c
if found, ok := c.commands.commands[path]; ok {
cmd = found
remaining = clean[i:]
break
}
}
cl.core.commands.mu.RUnlock()
c.commands.mu.RUnlock()

if cmd == nil {
if cl.banner != nil {
Expand All @@ -92,17 +99,17 @@ func (cl *Cli) Run(args ...string) Result {
}

// Build options from remaining args
opts := Options{}
opts := NewOptions()
for _, arg := range remaining {
key, val, valid := ParseFlag(arg)
if valid {
if Contains(arg, "=") {
opts = append(opts, Option{Key: key, Value: val})
opts.Set(key, val)
} else {
opts = append(opts, Option{Key: key, Value: true})
opts.Set(key, true)
}
} else if !IsFlag(arg) {
opts = append(opts, Option{Key: "_arg", Value: arg})
opts.Set("_arg", arg)
}
}

Expand All @@ -119,28 +126,29 @@ func (cl *Cli) Run(args ...string) Result {
//
// c.Cli().PrintHelp()
func (cl *Cli) PrintHelp() {
if cl.core == nil || cl.core.commands == nil {
c := cl.Core()
if c == nil || c.commands == nil {
return
}

name := ""
if cl.core.app != nil {
name = cl.core.app.Name
if c.app != nil {
name = c.app.Name
}
if name != "" {
cl.Print("%s commands:", name)
} else {
cl.Print("Commands:")
}

cl.core.commands.mu.RLock()
defer cl.core.commands.mu.RUnlock()
c.commands.mu.RLock()
defer c.commands.mu.RUnlock()

for path, cmd := range cl.core.commands.commands {
for path, cmd := range c.commands.commands {
if cmd.Hidden || (cmd.Action == nil && cmd.Lifecycle == nil) {
continue
}
tr := cl.core.I18n().Translate(cmd.I18nKey())
tr := c.I18n().Translate(cmd.I18nKey())
desc, _ := tr.Value.(string)
if desc == "" || desc == cmd.I18nKey() {
cl.Print(" %s", path)
Expand All @@ -162,8 +170,9 @@ 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
c := cl.Core()
if c != nil && c.app != nil && c.app.Name != "" {
return c.app.Name
}
return ""
}
4 changes: 2 additions & 2 deletions cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func TestCli_Good(t *testing.T) {
}

func TestCli_Banner_Good(t *testing.T) {
c := New(Options{{Key: "name", Value: "myapp"}})
c := New(WithOption("name", "myapp"))
assert.Equal(t, "myapp", c.Cli().Banner())
}

Expand Down Expand Up @@ -70,7 +70,7 @@ func TestCli_Run_NoCommand_Good(t *testing.T) {
}

func TestCli_PrintHelp_Good(t *testing.T) {
c := New(Options{{Key: "name", Value: "myapp"}})
c := New(WithOption("name", "myapp"))
c.Command("deploy", Command{Action: func(_ Options) Result { return Result{OK: true} }})
c.Command("serve", Command{Action: func(_ Options) Result { return Result{OK: true} }})
c.Cli().PrintHelp()
Expand Down
2 changes: 1 addition & 1 deletion command.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func (cmd *Command) I18nKey() string {

// Run executes the command's action with the given options.
//
// result := cmd.Run(core.Options{{Key: "target", Value: "homelab"}})
// result := cmd.Run(core.NewOptions(core.Option{Key: "target", Value: "homelab"}))
func (cmd *Command) Run(opts Options) Result {
if cmd.Action == nil {
return Result{E("core.Command.Run", Concat("command \"", cmd.Path, "\" is not executable"), nil), false}
Expand Down
8 changes: 4 additions & 4 deletions command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func TestCommand_Run_Good(t *testing.T) {
return Result{Value: Concat("hello ", opts.String("name")), OK: true}
}})
cmd := c.Command("greet").Value.(*Command)
r := cmd.Run(Options{{Key: "name", Value: "world"}})
r := cmd.Run(NewOptions(Option{Key: "name", Value: "world"}))
assert.True(t, r.OK)
assert.Equal(t, "hello world", r.Value)
}
Expand All @@ -46,7 +46,7 @@ func TestCommand_Run_NoAction_Good(t *testing.T) {
c := New()
c.Command("empty", Command{Description: "no action"})
cmd := c.Command("empty").Value.(*Command)
r := cmd.Run(Options{})
r := cmd.Run(NewOptions())
assert.False(t, r.OK)
}

Expand Down Expand Up @@ -111,7 +111,7 @@ func TestCommand_Lifecycle_NoImpl_Good(t *testing.T) {
}})
cmd := c.Command("serve").Value.(*Command)

r := cmd.Start(Options{})
r := cmd.Start(NewOptions())
assert.True(t, r.OK)
assert.Equal(t, "running", r.Value)

Expand Down Expand Up @@ -158,7 +158,7 @@ func TestCommand_Lifecycle_WithImpl_Good(t *testing.T) {
c.Command("daemon", Command{Lifecycle: lc})
cmd := c.Command("daemon").Value.(*Command)

r := cmd.Start(Options{})
r := cmd.Start(NewOptions())
assert.True(t, r.OK)
assert.True(t, lc.started)

Expand Down
9 changes: 9 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ type Config struct {
mu sync.RWMutex
}

// New initialises a Config with empty settings and features.
//
// cfg := (&core.Config{}).New()
func (e *Config) New() *Config {
e.ConfigOptions = &ConfigOptions{}
e.ConfigOptions.init()
return e
}

// Set stores a configuration value by key.
func (e *Config) Set(key string, val any) {
e.mu.Lock()
Expand Down
Loading