feat(cli): wire Core runtime with i18n and log services

- Add i18n service wrapping pkg/i18n for translations via cli.T()
- Add log service with levels (quiet/error/warn/info/debug)
- Wire cli.Init() in cmd.Execute() with explicit service names
- Fix main.go to print errors to stderr and exit with code 1
- Update runtime.go to accept additional services via Options

Services use WithName() to avoid name collision since both are
defined in pkg/cli (WithService would auto-name both "cli").

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-30 10:55:30 +00:00
parent 22aa1df30a
commit 5e2d058b26
5 changed files with 335 additions and 11 deletions

View file

@ -20,9 +20,15 @@ import (
"os"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/framework"
"github.com/spf13/cobra"
)
const (
appName = "core"
appVersion = "0.1.0"
)
// Terminal styles using Tailwind colour palette (from shared package).
var (
// coreStyle is used for primary headings and the CLI name.
@ -34,14 +40,29 @@ var (
// rootCmd is the base command for the CLI.
var rootCmd = &cobra.Command{
Use: "core",
Use: appName,
Short: "CLI tool for development and production",
Version: "0.1.0",
Version: appVersion,
}
// Execute initialises and runs the CLI application.
// Commands are registered based on build tags (see core_ci.go and core_dev.go).
func Execute() error {
// Initialise CLI runtime with services
if err := cli.Init(cli.Options{
AppName: appName,
Version: appVersion,
Services: []framework.Option{
framework.WithName("i18n", cli.NewI18nService(cli.I18nOptions{})),
framework.WithName("log", cli.NewLogService(cli.LogOptions{
Level: cli.LogLevelInfo,
})),
},
}); err != nil {
return err
}
defer cli.Shutdown()
return rootCmd.Execute()
}

View file

@ -1,12 +1,15 @@
package main
import (
"fmt"
"os"
"github.com/host-uk/core/cmd"
)
func main() {
err := cmd.Execute()
if err != nil {
return
if err := cmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

109
pkg/cli/i18n.go Normal file
View file

@ -0,0 +1,109 @@
package cli
import (
"context"
"github.com/host-uk/core/pkg/framework"
"github.com/host-uk/core/pkg/i18n"
)
// I18nService wraps i18n as a Core service.
type I18nService struct {
*framework.ServiceRuntime[I18nOptions]
svc *i18n.Service
}
// I18nOptions configures the i18n service.
type I18nOptions struct {
// Language overrides auto-detection (e.g., "en-GB", "de")
Language string
}
// NewI18nService creates an i18n service factory.
func NewI18nService(opts I18nOptions) func(*framework.Core) (any, error) {
return func(c *framework.Core) (any, error) {
svc, err := i18n.New()
if err != nil {
return nil, err
}
if opts.Language != "" {
svc.SetLanguage(opts.Language)
}
return &I18nService{
ServiceRuntime: framework.NewServiceRuntime(c, opts),
svc: svc,
}, nil
}
}
// OnStartup initialises the i18n service.
func (s *I18nService) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
return nil
}
// Queries for i18n service
// QueryTranslate requests a translation.
type QueryTranslate struct {
Key string
Args map[string]any
}
func (s *I18nService) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) {
switch m := q.(type) {
case QueryTranslate:
return s.svc.T(m.Key, m.Args), true, nil
}
return nil, false, nil
}
// T translates a key with optional arguments.
func (s *I18nService) T(key string, args ...map[string]any) string {
if len(args) > 0 {
return s.svc.T(key, args[0])
}
return s.svc.T(key)
}
// SetLanguage changes the current language.
func (s *I18nService) SetLanguage(lang string) {
s.svc.SetLanguage(lang)
}
// Language returns the current language.
func (s *I18nService) Language() string {
return s.svc.Language()
}
// AvailableLanguages returns all available languages.
func (s *I18nService) AvailableLanguages() []string {
return s.svc.AvailableLanguages()
}
// --- Package-level convenience ---
// T translates a key using the CLI's i18n service.
// Falls back to the global i18n.T if CLI not initialised.
func T(key string, args ...map[string]any) string {
if instance == nil {
// CLI not initialised, use global i18n
if len(args) > 0 {
return i18n.T(key, args[0])
}
return i18n.T(key)
}
svc, err := framework.ServiceFor[*I18nService](instance.core, "i18n")
if err != nil {
// i18n service not registered, use global
if len(args) > 0 {
return i18n.T(key, args[0])
}
return i18n.T(key)
}
return svc.T(key, args...)
}

186
pkg/cli/log.go Normal file
View file

@ -0,0 +1,186 @@
package cli
import (
"context"
"fmt"
"io"
"os"
"sync"
"time"
"github.com/host-uk/core/pkg/framework"
)
// LogLevel defines logging verbosity.
type LogLevel int
const (
LogLevelQuiet LogLevel = iota
LogLevelError
LogLevelWarn
LogLevelInfo
LogLevelDebug
)
// LogService provides structured logging for the CLI.
type LogService struct {
*framework.ServiceRuntime[LogOptions]
mu sync.RWMutex
level LogLevel
output io.Writer
}
// LogOptions configures the log service.
type LogOptions struct {
Level LogLevel
Output io.Writer // defaults to os.Stderr
}
// NewLogService creates a log service factory.
func NewLogService(opts LogOptions) func(*framework.Core) (any, error) {
return func(c *framework.Core) (any, error) {
output := opts.Output
if output == nil {
output = os.Stderr
}
return &LogService{
ServiceRuntime: framework.NewServiceRuntime(c, opts),
level: opts.Level,
output: output,
}, nil
}
}
// OnStartup registers query handlers.
func (s *LogService) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
// Queries and tasks for log service
// QueryLogLevel returns the current log level.
type QueryLogLevel struct{}
// TaskSetLogLevel changes the log level.
type TaskSetLogLevel struct {
Level LogLevel
}
func (s *LogService) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) {
switch q.(type) {
case QueryLogLevel:
s.mu.RLock()
defer s.mu.RUnlock()
return s.level, true, nil
}
return nil, false, nil
}
func (s *LogService) handleTask(c *framework.Core, t framework.Task) (any, bool, error) {
switch m := t.(type) {
case TaskSetLogLevel:
s.mu.Lock()
s.level = m.Level
s.mu.Unlock()
return nil, true, nil
}
return nil, false, nil
}
// SetLevel changes the log level.
func (s *LogService) SetLevel(level LogLevel) {
s.mu.Lock()
s.level = level
s.mu.Unlock()
}
// Level returns the current log level.
func (s *LogService) Level() LogLevel {
s.mu.RLock()
defer s.mu.RUnlock()
return s.level
}
func (s *LogService) shouldLog(level LogLevel) bool {
s.mu.RLock()
defer s.mu.RUnlock()
return level <= s.level
}
func (s *LogService) log(level, prefix, msg string) {
timestamp := time.Now().Format("15:04:05")
fmt.Fprintf(s.output, "%s %s %s\n", DimStyle.Render(timestamp), prefix, msg)
}
// Debug logs a debug message.
func (s *LogService) Debug(msg string) {
if s.shouldLog(LogLevelDebug) {
s.log("debug", DimStyle.Render("[DBG]"), msg)
}
}
// Infof logs an info message.
func (s *LogService) Infof(msg string) {
if s.shouldLog(LogLevelInfo) {
s.log("info", InfoStyle.Render("[INF]"), msg)
}
}
// Warnf logs a warning message.
func (s *LogService) Warnf(msg string) {
if s.shouldLog(LogLevelWarn) {
s.log("warn", WarningStyle.Render("[WRN]"), msg)
}
}
// Errorf logs an error message.
func (s *LogService) Errorf(msg string) {
if s.shouldLog(LogLevelError) {
s.log("error", ErrorStyle.Render("[ERR]"), msg)
}
}
// --- Package-level convenience ---
// Log returns the CLI's log service, or nil if not available.
func Log() *LogService {
if instance == nil {
return nil
}
svc, err := framework.ServiceFor[*LogService](instance.core, "log")
if err != nil {
return nil
}
return svc
}
// LogDebug logs a debug message if log service is available.
func LogDebug(msg string) {
if l := Log(); l != nil {
l.Debug(msg)
}
}
// LogInfo logs an info message if log service is available.
func LogInfo(msg string) {
if l := Log(); l != nil {
l.Infof(msg)
}
}
// LogWarn logs a warning message if log service is available.
func LogWarn(msg string) {
if l := Log(); l != nil {
l.Warnf(msg)
}
}
// LogError logs an error message if log service is available.
func LogError(msg string) {
if l := Log(); l != nil {
l.Errorf(msg)
}
}

View file

@ -38,21 +38,26 @@ type runtime struct {
// Options configures the CLI runtime.
type Options struct {
AppName string
Version string
AppName string
Version string
Services []framework.Option // Additional services to register
}
// Init initialises the global CLI runtime.
// Call this once at startup (typically in main.go).
// Call this once at startup (typically in main.go or cmd.Execute).
func Init(opts Options) error {
var initErr error
once.Do(func() {
ctx, cancel := context.WithCancel(context.Background())
c, err := framework.New(
// Build options: signal service + any additional services
coreOpts := []framework.Option{
framework.WithName("signal", newSignalService(cancel)),
framework.WithServiceLock(),
)
}
coreOpts = append(coreOpts, opts.Services...)
coreOpts = append(coreOpts, framework.WithServiceLock())
c, err := framework.New(coreOpts...)
if err != nil {
initErr = err
cancel()