From 3eee3569b9be328ca6d10749a5d1810678dcff65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 20:07:31 +0000 Subject: [PATCH] refactor: remove pkg/cli (moved to core/cli) pkg/cli now lives in forge.lthn.ai/core/cli/pkg/cli. Update go.mod deps to use main branch. Co-Authored-By: Claude Opus 4.6 --- go.mod | 2 +- pkg/cli/ansi.go | 163 ------------- pkg/cli/ansi_test.go | 97 -------- pkg/cli/app.go | 149 ------------ pkg/cli/app_test.go | 164 ------------- pkg/cli/check.go | 91 -------- pkg/cli/check_test.go | 49 ---- pkg/cli/command.go | 193 ---------------- pkg/cli/commands.go | 50 ---- pkg/cli/daemon.go | 455 ------------------------------------- pkg/cli/daemon_test.go | 254 --------------------- pkg/cli/errors.go | 162 ------------- pkg/cli/glyph.go | 92 -------- pkg/cli/glyph_maps.go | 25 -- pkg/cli/glyph_test.go | 23 -- pkg/cli/i18n.go | 170 -------------- pkg/cli/layout.go | 148 ------------ pkg/cli/layout_test.go | 25 -- pkg/cli/log.go | 115 ---------- pkg/cli/output.go | 195 ---------------- pkg/cli/output_test.go | 101 --------- pkg/cli/prompt.go | 75 ------ pkg/cli/render.go | 87 ------- pkg/cli/runtime.go | 219 ------------------ pkg/cli/strings.go | 48 ---- pkg/cli/styles.go | 211 ----------------- pkg/cli/utils.go | 505 ----------------------------------------- 27 files changed, 1 insertion(+), 3867 deletions(-) delete mode 100644 pkg/cli/ansi.go delete mode 100644 pkg/cli/ansi_test.go delete mode 100644 pkg/cli/app.go delete mode 100644 pkg/cli/app_test.go delete mode 100644 pkg/cli/check.go delete mode 100644 pkg/cli/check_test.go delete mode 100644 pkg/cli/command.go delete mode 100644 pkg/cli/commands.go delete mode 100644 pkg/cli/daemon.go delete mode 100644 pkg/cli/daemon_test.go delete mode 100644 pkg/cli/errors.go delete mode 100644 pkg/cli/glyph.go delete mode 100644 pkg/cli/glyph_maps.go delete mode 100644 pkg/cli/glyph_test.go delete mode 100644 pkg/cli/i18n.go delete mode 100644 pkg/cli/layout.go delete mode 100644 pkg/cli/layout_test.go delete mode 100644 pkg/cli/log.go delete mode 100644 pkg/cli/output.go delete mode 100644 pkg/cli/output_test.go delete mode 100644 pkg/cli/prompt.go delete mode 100644 pkg/cli/render.go delete mode 100644 pkg/cli/runtime.go delete mode 100644 pkg/cli/strings.go delete mode 100644 pkg/cli/styles.go delete mode 100644 pkg/cli/utils.go diff --git a/go.mod b/go.mod index 4dd2b9c..4eeed0c 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module forge.lthn.ai/core/go go 1.25.5 -require forge.lthn.ai/core/go-crypt v0.0.0 +require forge.lthn.ai/core/go-crypt main require ( github.com/Snider/Borg v0.2.0 diff --git a/pkg/cli/ansi.go b/pkg/cli/ansi.go deleted file mode 100644 index e4df66e..0000000 --- a/pkg/cli/ansi.go +++ /dev/null @@ -1,163 +0,0 @@ -package cli - -import ( - "fmt" - "os" - "strconv" - "strings" - "sync" -) - -// ANSI escape codes -const ( - ansiReset = "\033[0m" - ansiBold = "\033[1m" - ansiDim = "\033[2m" - ansiItalic = "\033[3m" - ansiUnderline = "\033[4m" -) - -var ( - colorEnabled = true - colorEnabledMu sync.RWMutex -) - -func init() { - // NO_COLOR standard: https://no-color.org/ - // If NO_COLOR is set (to any value, including empty), disable colors. - if _, exists := os.LookupEnv("NO_COLOR"); exists { - colorEnabled = false - return - } - - // TERM=dumb indicates a terminal without color support. - if os.Getenv("TERM") == "dumb" { - colorEnabled = false - } -} - -// ColorEnabled returns true if ANSI color output is enabled. -func ColorEnabled() bool { - colorEnabledMu.RLock() - defer colorEnabledMu.RUnlock() - return colorEnabled -} - -// SetColorEnabled enables or disables ANSI color output. -// This overrides the NO_COLOR environment variable check. -func SetColorEnabled(enabled bool) { - colorEnabledMu.Lock() - colorEnabled = enabled - colorEnabledMu.Unlock() -} - -// AnsiStyle represents terminal text styling. -// Use NewStyle() to create, chain methods, call Render(). -type AnsiStyle struct { - bold bool - dim bool - italic bool - underline bool - fg string - bg string -} - -// NewStyle creates a new empty style. -func NewStyle() *AnsiStyle { - return &AnsiStyle{} -} - -// Bold enables bold text. -func (s *AnsiStyle) Bold() *AnsiStyle { - s.bold = true - return s -} - -// Dim enables dim text. -func (s *AnsiStyle) Dim() *AnsiStyle { - s.dim = true - return s -} - -// Italic enables italic text. -func (s *AnsiStyle) Italic() *AnsiStyle { - s.italic = true - return s -} - -// Underline enables underlined text. -func (s *AnsiStyle) Underline() *AnsiStyle { - s.underline = true - return s -} - -// Foreground sets foreground color from hex string. -func (s *AnsiStyle) Foreground(hex string) *AnsiStyle { - s.fg = fgColorHex(hex) - return s -} - -// Background sets background color from hex string. -func (s *AnsiStyle) Background(hex string) *AnsiStyle { - s.bg = bgColorHex(hex) - return s -} - -// Render applies the style to text. -// Returns plain text if NO_COLOR is set or colors are disabled. -func (s *AnsiStyle) Render(text string) string { - if s == nil || !ColorEnabled() { - return text - } - - var codes []string - if s.bold { - codes = append(codes, ansiBold) - } - if s.dim { - codes = append(codes, ansiDim) - } - if s.italic { - codes = append(codes, ansiItalic) - } - if s.underline { - codes = append(codes, ansiUnderline) - } - if s.fg != "" { - codes = append(codes, s.fg) - } - if s.bg != "" { - codes = append(codes, s.bg) - } - - if len(codes) == 0 { - return text - } - - return strings.Join(codes, "") + text + ansiReset -} - -// fgColorHex converts a hex string to an ANSI foreground color code. -func fgColorHex(hex string) string { - r, g, b := hexToRGB(hex) - return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b) -} - -// bgColorHex converts a hex string to an ANSI background color code. -func bgColorHex(hex string) string { - r, g, b := hexToRGB(hex) - return fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b) -} - -// hexToRGB converts a hex string to RGB values. -func hexToRGB(hex string) (int, int, int) { - hex = strings.TrimPrefix(hex, "#") - if len(hex) != 6 { - return 255, 255, 255 - } - // Use 8-bit parsing since RGB values are 0-255, avoiding integer overflow on 32-bit systems. - r, _ := strconv.ParseUint(hex[0:2], 16, 8) - g, _ := strconv.ParseUint(hex[2:4], 16, 8) - b, _ := strconv.ParseUint(hex[4:6], 16, 8) - return int(r), int(g), int(b) -} diff --git a/pkg/cli/ansi_test.go b/pkg/cli/ansi_test.go deleted file mode 100644 index 1ec7a3e..0000000 --- a/pkg/cli/ansi_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package cli - -import ( - "strings" - "testing" -) - -func TestAnsiStyle_Render(t *testing.T) { - // Ensure colors are enabled for this test - SetColorEnabled(true) - defer SetColorEnabled(true) // Reset after test - - s := NewStyle().Bold().Foreground("#ff0000") - got := s.Render("test") - if got == "test" { - t.Error("Expected styled output") - } - if !strings.Contains(got, "test") { - t.Error("Output should contain text") - } - if !strings.Contains(got, "[1m") { - t.Error("Output should contain bold code") - } -} - -func TestColorEnabled_Good(t *testing.T) { - // Save original state - original := ColorEnabled() - defer SetColorEnabled(original) - - // Test enabling - SetColorEnabled(true) - if !ColorEnabled() { - t.Error("ColorEnabled should return true") - } - - // Test disabling - SetColorEnabled(false) - if ColorEnabled() { - t.Error("ColorEnabled should return false") - } -} - -func TestRender_ColorDisabled_Good(t *testing.T) { - // Save original state - original := ColorEnabled() - defer SetColorEnabled(original) - - // Disable colors - SetColorEnabled(false) - - s := NewStyle().Bold().Foreground("#ff0000") - got := s.Render("test") - - // Should return plain text without ANSI codes - if got != "test" { - t.Errorf("Expected plain 'test', got %q", got) - } -} - -func TestRender_ColorEnabled_Good(t *testing.T) { - // Save original state - original := ColorEnabled() - defer SetColorEnabled(original) - - // Enable colors - SetColorEnabled(true) - - s := NewStyle().Bold() - got := s.Render("test") - - // Should contain ANSI codes - if !strings.Contains(got, "\033[") { - t.Error("Expected ANSI codes when colors enabled") - } -} - -func TestUseASCII_Good(t *testing.T) { - // Save original state - original := ColorEnabled() - defer SetColorEnabled(original) - - // Enable first, then UseASCII should disable colors - SetColorEnabled(true) - UseASCII() - if ColorEnabled() { - t.Error("UseASCII should disable colors") - } -} - -func TestRender_NilStyle_Good(t *testing.T) { - var s *AnsiStyle - got := s.Render("test") - if got != "test" { - t.Errorf("Nil style should return plain text, got %q", got) - } -} diff --git a/pkg/cli/app.go b/pkg/cli/app.go deleted file mode 100644 index 7d2d4e2..0000000 --- a/pkg/cli/app.go +++ /dev/null @@ -1,149 +0,0 @@ -package cli - -import ( - "fmt" - "os" - "runtime/debug" - - "forge.lthn.ai/core/go/pkg/framework" - "forge.lthn.ai/core/go/pkg/log" - "forge.lthn.ai/core/go/pkg/workspace" - "github.com/spf13/cobra" -) - -const ( - // AppName is the CLI application name. - AppName = "core" -) - -// Build-time variables set via ldflags (SemVer 2.0.0): -// -// go build -ldflags="-X forge.lthn.ai/core/go/pkg/cli.AppVersion=1.2.0 \ -// -X forge.lthn.ai/core/go/pkg/cli.BuildCommit=df94c24 \ -// -X forge.lthn.ai/core/go/pkg/cli.BuildDate=2026-02-06 \ -// -X forge.lthn.ai/core/go/pkg/cli.BuildPreRelease=dev.8" -var ( - AppVersion = "0.0.0" - BuildCommit = "unknown" - BuildDate = "unknown" - BuildPreRelease = "" -) - -// SemVer returns the full SemVer 2.0.0 version string. -// - Release: 1.2.0 -// - Pre-release: 1.2.0-dev.8 -// - Full: 1.2.0-dev.8+df94c24.20260206 -func SemVer() string { - v := AppVersion - if BuildPreRelease != "" { - v += "-" + BuildPreRelease - } - if BuildCommit != "unknown" { - v += "+" + BuildCommit - if BuildDate != "unknown" { - v += "." + BuildDate - } - } - return v -} - -// Main initialises and runs the CLI application. -// This is the main entry point for the CLI. -// Exits with code 1 on error or panic. -func Main() { - // Recovery from panics - defer func() { - if r := recover(); r != nil { - log.Error("recovered from panic", "error", r, "stack", string(debug.Stack())) - Shutdown() - Fatal(fmt.Errorf("panic: %v", r)) - } - }() - - // Initialise CLI runtime with services - if err := Init(Options{ - AppName: AppName, - Version: SemVer(), - Services: []framework.Option{ - framework.WithName("i18n", NewI18nService(I18nOptions{})), - framework.WithName("log", NewLogService(log.Options{ - Level: log.LevelInfo, - })), - framework.WithName("workspace", workspace.New), - }, - }); err != nil { - Error(err.Error()) - os.Exit(1) - } - defer Shutdown() - - // Add completion command to the CLI's root - RootCmd().AddCommand(completionCmd) - - if err := Execute(); err != nil { - code := 1 - var exitErr *ExitError - if As(err, &exitErr) { - code = exitErr.Code - } - Error(err.Error()) - os.Exit(code) - } -} - -// completionCmd generates shell completion scripts. -var completionCmd = &cobra.Command{ - Use: "completion [bash|zsh|fish|powershell]", - Short: "Generate shell completion script", - Long: `Generate shell completion script for the specified shell. - -To load completions: - -Bash: - $ source <(core completion bash) - - # To load completions for each session, execute once: - # Linux: - $ core completion bash > /etc/bash_completion.d/core - # macOS: - $ core completion bash > $(brew --prefix)/etc/bash_completion.d/core - -Zsh: - # If shell completion is not already enabled in your environment, - # you will need to enable it. You can execute the following once: - $ echo "autoload -U compinit; compinit" >> ~/.zshrc - - # To load completions for each session, execute once: - $ core completion zsh > "${fpath[1]}/_core" - - # You will need to start a new shell for this setup to take effect. - -Fish: - $ core completion fish | source - - # To load completions for each session, execute once: - $ core completion fish > ~/.config/fish/completions/core.fish - -PowerShell: - PS> core completion powershell | Out-String | Invoke-Expression - - # To load completions for every new session, run: - PS> core completion powershell > core.ps1 - # and source this file from your PowerShell profile. -`, - DisableFlagsInUseLine: true, - ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, - Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - Run: func(cmd *cobra.Command, args []string) { - switch args[0] { - case "bash": - _ = cmd.Root().GenBashCompletion(os.Stdout) - case "zsh": - _ = cmd.Root().GenZshCompletion(os.Stdout) - case "fish": - _ = cmd.Root().GenFishCompletion(os.Stdout, true) - case "powershell": - _ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) - } - }, -} diff --git a/pkg/cli/app_test.go b/pkg/cli/app_test.go deleted file mode 100644 index c11d5fe..0000000 --- a/pkg/cli/app_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package cli - -import ( - "bytes" - "fmt" - "runtime/debug" - "sync" - "testing" - - "github.com/stretchr/testify/assert" -) - -// TestPanicRecovery_Good verifies that the panic recovery mechanism -// catches panics and calls the appropriate shutdown and error handling. -func TestPanicRecovery_Good(t *testing.T) { - t.Run("recovery captures panic value and stack", func(t *testing.T) { - var recovered any - var capturedStack []byte - var shutdownCalled bool - - // Simulate the panic recovery pattern from Main() - func() { - defer func() { - if r := recover(); r != nil { - recovered = r - capturedStack = debug.Stack() - shutdownCalled = true // simulates Shutdown() call - } - }() - - panic("test panic") - }() - - assert.Equal(t, "test panic", recovered) - assert.True(t, shutdownCalled, "Shutdown should be called after panic recovery") - assert.NotEmpty(t, capturedStack, "Stack trace should be captured") - assert.Contains(t, string(capturedStack), "TestPanicRecovery_Good") - }) - - t.Run("recovery handles error type panics", func(t *testing.T) { - var recovered any - - func() { - defer func() { - if r := recover(); r != nil { - recovered = r - } - }() - - panic(fmt.Errorf("error panic")) - }() - - err, ok := recovered.(error) - assert.True(t, ok, "Recovered value should be an error") - assert.Equal(t, "error panic", err.Error()) - }) - - t.Run("recovery handles nil panic gracefully", func(t *testing.T) { - recoveryExecuted := false - - func() { - defer func() { - if r := recover(); r != nil { - recoveryExecuted = true - } - }() - - // No panic occurs - }() - - assert.False(t, recoveryExecuted, "Recovery block should not execute without panic") - }) -} - -// TestPanicRecovery_Bad tests error conditions in panic recovery. -func TestPanicRecovery_Bad(t *testing.T) { - t.Run("recovery handles concurrent panics", func(t *testing.T) { - var wg sync.WaitGroup - recoveryCount := 0 - var mu sync.Mutex - - for i := 0; i < 3; i++ { - wg.Add(1) - go func(id int) { - defer wg.Done() - defer func() { - if r := recover(); r != nil { - mu.Lock() - recoveryCount++ - mu.Unlock() - } - }() - - panic(fmt.Sprintf("panic from goroutine %d", id)) - }(i) - } - - wg.Wait() - assert.Equal(t, 3, recoveryCount, "All goroutine panics should be recovered") - }) -} - -// TestPanicRecovery_Ugly tests edge cases in panic recovery. -func TestPanicRecovery_Ugly(t *testing.T) { - t.Run("recovery handles typed panic values", func(t *testing.T) { - type customError struct { - code int - msg string - } - - var recovered any - - func() { - defer func() { - recovered = recover() - }() - - panic(customError{code: 500, msg: "internal error"}) - }() - - ce, ok := recovered.(customError) - assert.True(t, ok, "Should recover custom type") - assert.Equal(t, 500, ce.code) - assert.Equal(t, "internal error", ce.msg) - }) -} - -// TestMainPanicRecoveryPattern verifies the exact pattern used in Main(). -func TestMainPanicRecoveryPattern(t *testing.T) { - t.Run("pattern logs error and calls shutdown", func(t *testing.T) { - var logBuffer bytes.Buffer - var shutdownCalled bool - var fatalErr error - - // Mock implementations - mockLogError := func(msg string, args ...any) { - fmt.Fprintf(&logBuffer, msg, args...) - } - mockShutdown := func() { - shutdownCalled = true - } - mockFatal := func(err error) { - fatalErr = err - } - - // Execute the pattern from Main() - func() { - defer func() { - if r := recover(); r != nil { - mockLogError("recovered from panic: %v", r) - mockShutdown() - mockFatal(fmt.Errorf("panic: %v", r)) - } - }() - - panic("simulated crash") - }() - - assert.Contains(t, logBuffer.String(), "recovered from panic: simulated crash") - assert.True(t, shutdownCalled, "Shutdown must be called on panic") - assert.NotNil(t, fatalErr, "Fatal must be called with error") - assert.Equal(t, "panic: simulated crash", fatalErr.Error()) - }) -} diff --git a/pkg/cli/check.go b/pkg/cli/check.go deleted file mode 100644 index 499cd89..0000000 --- a/pkg/cli/check.go +++ /dev/null @@ -1,91 +0,0 @@ -package cli - -import "fmt" - -// CheckBuilder provides fluent API for check results. -type CheckBuilder struct { - name string - status string - style *AnsiStyle - icon string - duration string -} - -// Check starts building a check result line. -// -// cli.Check("audit").Pass() -// cli.Check("fmt").Fail().Duration("2.3s") -// cli.Check("test").Skip() -func Check(name string) *CheckBuilder { - return &CheckBuilder{name: name} -} - -// Pass marks the check as passed. -func (c *CheckBuilder) Pass() *CheckBuilder { - c.status = "passed" - c.style = SuccessStyle - c.icon = Glyph(":check:") - return c -} - -// Fail marks the check as failed. -func (c *CheckBuilder) Fail() *CheckBuilder { - c.status = "failed" - c.style = ErrorStyle - c.icon = Glyph(":cross:") - return c -} - -// Skip marks the check as skipped. -func (c *CheckBuilder) Skip() *CheckBuilder { - c.status = "skipped" - c.style = DimStyle - c.icon = "-" - return c -} - -// Warn marks the check as warning. -func (c *CheckBuilder) Warn() *CheckBuilder { - c.status = "warning" - c.style = WarningStyle - c.icon = Glyph(":warn:") - return c -} - -// Duration adds duration to the check result. -func (c *CheckBuilder) Duration(d string) *CheckBuilder { - c.duration = d - return c -} - -// Message adds a custom message instead of status. -func (c *CheckBuilder) Message(msg string) *CheckBuilder { - c.status = msg - return c -} - -// String returns the formatted check line. -func (c *CheckBuilder) String() string { - icon := c.icon - if c.style != nil { - icon = c.style.Render(c.icon) - } - - status := c.status - if c.style != nil && c.status != "" { - status = c.style.Render(c.status) - } - - if c.duration != "" { - return fmt.Sprintf(" %s %-20s %-10s %s", icon, c.name, status, DimStyle.Render(c.duration)) - } - if status != "" { - return fmt.Sprintf(" %s %s %s", icon, c.name, status) - } - return fmt.Sprintf(" %s %s", icon, c.name) -} - -// Print outputs the check result. -func (c *CheckBuilder) Print() { - fmt.Println(c.String()) -} diff --git a/pkg/cli/check_test.go b/pkg/cli/check_test.go deleted file mode 100644 index 760853c..0000000 --- a/pkg/cli/check_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package cli - -import "testing" - -func TestCheckBuilder(t *testing.T) { - UseASCII() // Deterministic output - - // Pass - c := Check("foo").Pass() - got := c.String() - if got == "" { - t.Error("Empty output for Pass") - } - - // Fail - c = Check("foo").Fail() - got = c.String() - if got == "" { - t.Error("Empty output for Fail") - } - - // Skip - c = Check("foo").Skip() - got = c.String() - if got == "" { - t.Error("Empty output for Skip") - } - - // Warn - c = Check("foo").Warn() - got = c.String() - if got == "" { - t.Error("Empty output for Warn") - } - - // Duration - c = Check("foo").Pass().Duration("1s") - got = c.String() - if got == "" { - t.Error("Empty output for Duration") - } - - // Message - c = Check("foo").Message("status") - got = c.String() - if got == "" { - t.Error("Empty output for Message") - } -} diff --git a/pkg/cli/command.go b/pkg/cli/command.go deleted file mode 100644 index 31b6e1b..0000000 --- a/pkg/cli/command.go +++ /dev/null @@ -1,193 +0,0 @@ -package cli - -import ( - "github.com/spf13/cobra" -) - -// ───────────────────────────────────────────────────────────────────────────── -// Command Type Re-export -// ───────────────────────────────────────────────────────────────────────────── - -// Command is the cobra command type. -// Re-exported for convenience so packages don't need to import cobra directly. -type Command = cobra.Command - -// ───────────────────────────────────────────────────────────────────────────── -// Command Builders -// ───────────────────────────────────────────────────────────────────────────── - -// NewCommand creates a new command with a RunE handler. -// This is the standard way to create commands that may return errors. -// -// cmd := cli.NewCommand("build", "Build the project", "", func(cmd *cli.Command, args []string) error { -// // Build logic -// return nil -// }) -func NewCommand(use, short, long string, run func(cmd *Command, args []string) error) *Command { - cmd := &Command{ - Use: use, - Short: short, - RunE: run, - } - if long != "" { - cmd.Long = long - } - return cmd -} - -// NewGroup creates a new command group (no RunE). -// Use this for parent commands that only contain subcommands. -// -// devCmd := cli.NewGroup("dev", "Development commands", "") -// devCmd.AddCommand(buildCmd, testCmd) -func NewGroup(use, short, long string) *Command { - cmd := &Command{ - Use: use, - Short: short, - } - if long != "" { - cmd.Long = long - } - return cmd -} - -// NewRun creates a new command with a simple Run handler (no error return). -// Use when the command cannot fail. -// -// cmd := cli.NewRun("version", "Show version", "", func(cmd *cli.Command, args []string) { -// cli.Println("v1.0.0") -// }) -func NewRun(use, short, long string, run func(cmd *Command, args []string)) *Command { - cmd := &Command{ - Use: use, - Short: short, - Run: run, - } - if long != "" { - cmd.Long = long - } - return cmd -} - -// ───────────────────────────────────────────────────────────────────────────── -// Flag Helpers -// ───────────────────────────────────────────────────────────────────────────── - -// StringFlag adds a string flag to a command. -// The value will be stored in the provided pointer. -// -// var output string -// cli.StringFlag(cmd, &output, "output", "o", "", "Output file path") -func StringFlag(cmd *Command, ptr *string, name, short, def, usage string) { - if short != "" { - cmd.Flags().StringVarP(ptr, name, short, def, usage) - } else { - cmd.Flags().StringVar(ptr, name, def, usage) - } -} - -// BoolFlag adds a boolean flag to a command. -// The value will be stored in the provided pointer. -// -// var verbose bool -// cli.BoolFlag(cmd, &verbose, "verbose", "v", false, "Enable verbose output") -func BoolFlag(cmd *Command, ptr *bool, name, short string, def bool, usage string) { - if short != "" { - cmd.Flags().BoolVarP(ptr, name, short, def, usage) - } else { - cmd.Flags().BoolVar(ptr, name, def, usage) - } -} - -// IntFlag adds an integer flag to a command. -// The value will be stored in the provided pointer. -// -// var count int -// cli.IntFlag(cmd, &count, "count", "n", 10, "Number of items") -func IntFlag(cmd *Command, ptr *int, name, short string, def int, usage string) { - if short != "" { - cmd.Flags().IntVarP(ptr, name, short, def, usage) - } else { - cmd.Flags().IntVar(ptr, name, def, usage) - } -} - -// StringSliceFlag adds a string slice flag to a command. -// The value will be stored in the provided pointer. -// -// var tags []string -// cli.StringSliceFlag(cmd, &tags, "tag", "t", nil, "Tags to apply") -func StringSliceFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) { - if short != "" { - cmd.Flags().StringSliceVarP(ptr, name, short, def, usage) - } else { - cmd.Flags().StringSliceVar(ptr, name, def, usage) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Persistent Flag Helpers -// ───────────────────────────────────────────────────────────────────────────── - -// PersistentStringFlag adds a persistent string flag (inherited by subcommands). -func PersistentStringFlag(cmd *Command, ptr *string, name, short, def, usage string) { - if short != "" { - cmd.PersistentFlags().StringVarP(ptr, name, short, def, usage) - } else { - cmd.PersistentFlags().StringVar(ptr, name, def, usage) - } -} - -// PersistentBoolFlag adds a persistent boolean flag (inherited by subcommands). -func PersistentBoolFlag(cmd *Command, ptr *bool, name, short string, def bool, usage string) { - if short != "" { - cmd.PersistentFlags().BoolVarP(ptr, name, short, def, usage) - } else { - cmd.PersistentFlags().BoolVar(ptr, name, def, usage) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Command Configuration -// ───────────────────────────────────────────────────────────────────────────── - -// WithArgs sets the Args validation function for a command. -// Returns the command for chaining. -// -// cmd := cli.NewCommand("build", "Build", "", run).WithArgs(cobra.ExactArgs(1)) -func WithArgs(cmd *Command, args cobra.PositionalArgs) *Command { - cmd.Args = args - return cmd -} - -// WithExample sets the Example field for a command. -// Returns the command for chaining. -func WithExample(cmd *Command, example string) *Command { - cmd.Example = example - return cmd -} - -// ExactArgs returns a PositionalArgs that accepts exactly N arguments. -func ExactArgs(n int) cobra.PositionalArgs { - return cobra.ExactArgs(n) -} - -// MinimumNArgs returns a PositionalArgs that accepts minimum N arguments. -func MinimumNArgs(n int) cobra.PositionalArgs { - return cobra.MinimumNArgs(n) -} - -// MaximumNArgs returns a PositionalArgs that accepts maximum N arguments. -func MaximumNArgs(n int) cobra.PositionalArgs { - return cobra.MaximumNArgs(n) -} - -// NoArgs returns a PositionalArgs that accepts no arguments. -func NoArgs() cobra.PositionalArgs { - return cobra.NoArgs -} - -// ArbitraryArgs returns a PositionalArgs that accepts any arguments. -func ArbitraryArgs() cobra.PositionalArgs { - return cobra.ArbitraryArgs -} diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go deleted file mode 100644 index 20ea2da..0000000 --- a/pkg/cli/commands.go +++ /dev/null @@ -1,50 +0,0 @@ -// Package cli provides the CLI runtime and utilities. -package cli - -import ( - "sync" - - "github.com/spf13/cobra" -) - -// CommandRegistration is a function that adds commands to the root. -type CommandRegistration func(root *cobra.Command) - -var ( - registeredCommands []CommandRegistration - registeredCommandsMu sync.Mutex - commandsAttached bool -) - -// RegisterCommands registers a function that adds commands to the CLI. -// Call this in your package's init() to register commands. -// -// func init() { -// cli.RegisterCommands(AddCommands) -// } -// -// func AddCommands(root *cobra.Command) { -// root.AddCommand(myCmd) -// } -func RegisterCommands(fn CommandRegistration) { - registeredCommandsMu.Lock() - defer registeredCommandsMu.Unlock() - registeredCommands = append(registeredCommands, fn) - - // If commands already attached (CLI already running), attach immediately - if commandsAttached && instance != nil && instance.root != nil { - fn(instance.root) - } -} - -// attachRegisteredCommands calls all registered command functions. -// Called by Init() after creating the root command. -func attachRegisteredCommands(root *cobra.Command) { - registeredCommandsMu.Lock() - defer registeredCommandsMu.Unlock() - - for _, fn := range registeredCommands { - fn(root) - } - commandsAttached = true -} diff --git a/pkg/cli/daemon.go b/pkg/cli/daemon.go deleted file mode 100644 index bdf42c7..0000000 --- a/pkg/cli/daemon.go +++ /dev/null @@ -1,455 +0,0 @@ -// Package cli provides the CLI runtime and utilities. -package cli - -import ( - "context" - "fmt" - "net" - "net/http" - "os" - "path/filepath" - "strconv" - "sync" - "syscall" - "time" - - "forge.lthn.ai/core/go/pkg/io" - "golang.org/x/term" -) - -// Mode represents the CLI execution mode. -type Mode int - -const ( - // ModeInteractive indicates TTY attached with coloured output. - ModeInteractive Mode = iota - // ModePipe indicates stdout is piped, colours disabled. - ModePipe - // ModeDaemon indicates headless execution, log-only output. - ModeDaemon -) - -// String returns the string representation of the Mode. -func (m Mode) String() string { - switch m { - case ModeInteractive: - return "interactive" - case ModePipe: - return "pipe" - case ModeDaemon: - return "daemon" - default: - return "unknown" - } -} - -// DetectMode determines the execution mode based on environment. -// Checks CORE_DAEMON env var first, then TTY status. -func DetectMode() Mode { - if os.Getenv("CORE_DAEMON") == "1" { - return ModeDaemon - } - if !IsTTY() { - return ModePipe - } - return ModeInteractive -} - -// IsTTY returns true if stdout is a terminal. -func IsTTY() bool { - return term.IsTerminal(int(os.Stdout.Fd())) -} - -// IsStdinTTY returns true if stdin is a terminal. -func IsStdinTTY() bool { - return term.IsTerminal(int(os.Stdin.Fd())) -} - -// IsStderrTTY returns true if stderr is a terminal. -func IsStderrTTY() bool { - return term.IsTerminal(int(os.Stderr.Fd())) -} - -// --- PID File Management --- - -// PIDFile manages a process ID file for single-instance enforcement. -type PIDFile struct { - medium io.Medium - path string - mu sync.Mutex -} - -// NewPIDFile creates a PID file manager. -// If medium is nil, uses io.Local (filesystem). -func NewPIDFile(medium io.Medium, path string) *PIDFile { - if medium == nil { - medium = io.Local - } - return &PIDFile{medium: medium, path: path} -} - -// Acquire writes the current PID to the file. -// Returns error if another instance is running. -func (p *PIDFile) Acquire() error { - p.mu.Lock() - defer p.mu.Unlock() - - // Check if PID file exists - if data, err := p.medium.Read(p.path); err == nil { - pid, err := strconv.Atoi(data) - if err == nil && pid > 0 { - // Check if process is still running - if process, err := os.FindProcess(pid); err == nil { - if err := process.Signal(syscall.Signal(0)); err == nil { - return fmt.Errorf("another instance is running (PID %d)", pid) - } - } - } - // Stale PID file, remove it - _ = p.medium.Delete(p.path) - } - - // Ensure directory exists - if dir := filepath.Dir(p.path); dir != "." { - if err := p.medium.EnsureDir(dir); err != nil { - return fmt.Errorf("failed to create PID directory: %w", err) - } - } - - // Write current PID - pid := os.Getpid() - if err := p.medium.Write(p.path, strconv.Itoa(pid)); err != nil { - return fmt.Errorf("failed to write PID file: %w", err) - } - - return nil -} - -// Release removes the PID file. -func (p *PIDFile) Release() error { - p.mu.Lock() - defer p.mu.Unlock() - return p.medium.Delete(p.path) -} - -// Path returns the PID file path. -func (p *PIDFile) Path() string { - return p.path -} - -// --- Health Check Server --- - -// HealthServer provides a minimal HTTP health check endpoint. -type HealthServer struct { - addr string - server *http.Server - listener net.Listener - mu sync.Mutex - ready bool - checks []HealthCheck -} - -// HealthCheck is a function that returns nil if healthy. -type HealthCheck func() error - -// NewHealthServer creates a health check server. -func NewHealthServer(addr string) *HealthServer { - return &HealthServer{ - addr: addr, - ready: true, - } -} - -// AddCheck registers a health check function. -func (h *HealthServer) AddCheck(check HealthCheck) { - h.mu.Lock() - h.checks = append(h.checks, check) - h.mu.Unlock() -} - -// SetReady sets the readiness status. -func (h *HealthServer) SetReady(ready bool) { - h.mu.Lock() - h.ready = ready - h.mu.Unlock() -} - -// Start begins serving health check endpoints. -// Endpoints: -// - /health - liveness probe (always 200 if server is up) -// - /ready - readiness probe (200 if ready, 503 if not) -func (h *HealthServer) Start() error { - mux := http.NewServeMux() - - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - h.mu.Lock() - checks := h.checks - h.mu.Unlock() - - for _, check := range checks { - if err := check(); err != nil { - w.WriteHeader(http.StatusServiceUnavailable) - _, _ = fmt.Fprintf(w, "unhealthy: %v\n", err) - return - } - } - - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprintln(w, "ok") - }) - - mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) { - h.mu.Lock() - ready := h.ready - h.mu.Unlock() - - if !ready { - w.WriteHeader(http.StatusServiceUnavailable) - _, _ = fmt.Fprintln(w, "not ready") - return - } - - w.WriteHeader(http.StatusOK) - _, _ = fmt.Fprintln(w, "ready") - }) - - listener, err := net.Listen("tcp", h.addr) - if err != nil { - return fmt.Errorf("failed to listen on %s: %w", h.addr, err) - } - - h.listener = listener - h.server = &http.Server{Handler: mux} - - go func() { - if err := h.server.Serve(listener); err != http.ErrServerClosed { - LogError(fmt.Sprintf("health server error: %v", err)) - } - }() - - return nil -} - -// Stop gracefully shuts down the health server. -func (h *HealthServer) Stop(ctx context.Context) error { - if h.server == nil { - return nil - } - return h.server.Shutdown(ctx) -} - -// Addr returns the actual address the server is listening on. -// Useful when using port 0 for dynamic port assignment. -func (h *HealthServer) Addr() string { - if h.listener != nil { - return h.listener.Addr().String() - } - return h.addr -} - -// --- Daemon Runner --- - -// DaemonOptions configures daemon mode execution. -type DaemonOptions struct { - // Medium is the filesystem for PID file operations. - // If nil, uses io.Local (filesystem). - Medium io.Medium - - // PIDFile path for single-instance enforcement. - // Leave empty to skip PID file management. - PIDFile string - - // ShutdownTimeout is the maximum time to wait for graceful shutdown. - // Default: 30 seconds. - ShutdownTimeout time.Duration - - // HealthAddr is the address for health check endpoints. - // Example: ":8080", "127.0.0.1:9000" - // Leave empty to disable health checks. - HealthAddr string - - // HealthChecks are additional health check functions. - HealthChecks []HealthCheck - - // OnReload is called when SIGHUP is received. - // Use for config reloading. Leave nil to ignore SIGHUP. - OnReload func() error -} - -// Daemon manages daemon lifecycle. -type Daemon struct { - opts DaemonOptions - pid *PIDFile - health *HealthServer - reload chan struct{} - running bool - mu sync.Mutex -} - -// NewDaemon creates a daemon runner with the given options. -func NewDaemon(opts DaemonOptions) *Daemon { - if opts.ShutdownTimeout == 0 { - opts.ShutdownTimeout = 30 * time.Second - } - - d := &Daemon{ - opts: opts, - reload: make(chan struct{}, 1), - } - - if opts.PIDFile != "" { - d.pid = NewPIDFile(opts.Medium, opts.PIDFile) - } - - if opts.HealthAddr != "" { - d.health = NewHealthServer(opts.HealthAddr) - for _, check := range opts.HealthChecks { - d.health.AddCheck(check) - } - } - - return d -} - -// Start initialises the daemon (PID file, health server). -// Call this after cli.Init(). -func (d *Daemon) Start() error { - d.mu.Lock() - defer d.mu.Unlock() - - if d.running { - return fmt.Errorf("daemon already running") - } - - // Acquire PID file - if d.pid != nil { - if err := d.pid.Acquire(); err != nil { - return err - } - } - - // Start health server - if d.health != nil { - if err := d.health.Start(); err != nil { - if d.pid != nil { - _ = d.pid.Release() - } - return err - } - } - - d.running = true - return nil -} - -// Run blocks until the context is cancelled or a signal is received. -// Handles graceful shutdown with the configured timeout. -func (d *Daemon) Run(ctx context.Context) error { - d.mu.Lock() - if !d.running { - d.mu.Unlock() - return fmt.Errorf("daemon not started - call Start() first") - } - d.mu.Unlock() - - // Wait for context cancellation (from signal handler) - <-ctx.Done() - - return d.Stop() -} - -// Stop performs graceful shutdown. -func (d *Daemon) Stop() error { - d.mu.Lock() - defer d.mu.Unlock() - - if !d.running { - return nil - } - - var errs []error - - // Create shutdown context with timeout - shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout) - defer cancel() - - // Stop health server - if d.health != nil { - d.health.SetReady(false) - if err := d.health.Stop(shutdownCtx); err != nil { - errs = append(errs, fmt.Errorf("health server: %w", err)) - } - } - - // Release PID file - if d.pid != nil { - if err := d.pid.Release(); err != nil && !os.IsNotExist(err) { - errs = append(errs, fmt.Errorf("pid file: %w", err)) - } - } - - d.running = false - - if len(errs) > 0 { - return fmt.Errorf("shutdown errors: %v", errs) - } - return nil -} - -// SetReady sets the daemon readiness status for health checks. -func (d *Daemon) SetReady(ready bool) { - if d.health != nil { - d.health.SetReady(ready) - } -} - -// HealthAddr returns the health server address, or empty if disabled. -func (d *Daemon) HealthAddr() string { - if d.health != nil { - return d.health.Addr() - } - return "" -} - -// --- Convenience Functions --- - -// Run blocks until context is cancelled or signal received. -// Simple helper for daemon mode without advanced features. -// -// cli.Init(cli.Options{AppName: "myapp"}) -// defer cli.Shutdown() -// cli.Run(cli.Context()) -func Run(ctx context.Context) error { - mustInit() - <-ctx.Done() - return ctx.Err() -} - -// RunWithTimeout wraps Run with a graceful shutdown timeout. -// The returned function should be deferred to replace cli.Shutdown(). -// -// cli.Init(cli.Options{AppName: "myapp"}) -// shutdown := cli.RunWithTimeout(30 * time.Second) -// defer shutdown() -// cli.Run(cli.Context()) -func RunWithTimeout(timeout time.Duration) func() { - return func() { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - // Create done channel for shutdown completion - done := make(chan struct{}) - go func() { - Shutdown() - close(done) - }() - - select { - case <-done: - // Clean shutdown - case <-ctx.Done(): - // Timeout - force exit - LogWarn("shutdown timeout exceeded, forcing exit") - } - } -} diff --git a/pkg/cli/daemon_test.go b/pkg/cli/daemon_test.go deleted file mode 100644 index a67c162..0000000 --- a/pkg/cli/daemon_test.go +++ /dev/null @@ -1,254 +0,0 @@ -package cli - -import ( - "context" - "net/http" - "testing" - "time" - - "forge.lthn.ai/core/go/pkg/io" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDetectMode(t *testing.T) { - t.Run("daemon mode from env", func(t *testing.T) { - t.Setenv("CORE_DAEMON", "1") - assert.Equal(t, ModeDaemon, DetectMode()) - }) - - t.Run("mode string", func(t *testing.T) { - assert.Equal(t, "interactive", ModeInteractive.String()) - assert.Equal(t, "pipe", ModePipe.String()) - assert.Equal(t, "daemon", ModeDaemon.String()) - assert.Equal(t, "unknown", Mode(99).String()) - }) -} - -func TestPIDFile(t *testing.T) { - t.Run("acquire and release", func(t *testing.T) { - m := io.NewMockMedium() - pidPath := "/tmp/test.pid" - - pid := NewPIDFile(m, pidPath) - - // Acquire should succeed - err := pid.Acquire() - require.NoError(t, err) - - // File should exist with our PID - data, err := m.Read(pidPath) - require.NoError(t, err) - assert.NotEmpty(t, data) - - // Release should remove file - err = pid.Release() - require.NoError(t, err) - - assert.False(t, m.Exists(pidPath)) - }) - - t.Run("stale pid file", func(t *testing.T) { - m := io.NewMockMedium() - pidPath := "/tmp/stale.pid" - - // Write a stale PID (non-existent process) - err := m.Write(pidPath, "999999999") - require.NoError(t, err) - - pid := NewPIDFile(m, pidPath) - - // Should acquire successfully (stale PID removed) - err = pid.Acquire() - require.NoError(t, err) - - err = pid.Release() - require.NoError(t, err) - }) - - t.Run("creates parent directory", func(t *testing.T) { - m := io.NewMockMedium() - pidPath := "/tmp/subdir/nested/test.pid" - - pid := NewPIDFile(m, pidPath) - - err := pid.Acquire() - require.NoError(t, err) - - assert.True(t, m.Exists(pidPath)) - - err = pid.Release() - require.NoError(t, err) - }) - - t.Run("path getter", func(t *testing.T) { - m := io.NewMockMedium() - pid := NewPIDFile(m, "/tmp/test.pid") - assert.Equal(t, "/tmp/test.pid", pid.Path()) - }) -} - -func TestHealthServer(t *testing.T) { - t.Run("health and ready endpoints", func(t *testing.T) { - hs := NewHealthServer("127.0.0.1:0") // Random port - - err := hs.Start() - require.NoError(t, err) - defer func() { _ = hs.Stop(context.Background()) }() - - addr := hs.Addr() - require.NotEmpty(t, addr) - - // Health should be OK - resp, err := http.Get("http://" + addr + "/health") - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - _ = resp.Body.Close() - - // Ready should be OK by default - resp, err = http.Get("http://" + addr + "/ready") - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - _ = resp.Body.Close() - - // Set not ready - hs.SetReady(false) - - resp, err = http.Get("http://" + addr + "/ready") - require.NoError(t, err) - assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) - _ = resp.Body.Close() - }) - - t.Run("with health checks", func(t *testing.T) { - hs := NewHealthServer("127.0.0.1:0") - - healthy := true - hs.AddCheck(func() error { - if !healthy { - return assert.AnError - } - return nil - }) - - err := hs.Start() - require.NoError(t, err) - defer func() { _ = hs.Stop(context.Background()) }() - - addr := hs.Addr() - - // Should be healthy - resp, err := http.Get("http://" + addr + "/health") - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - _ = resp.Body.Close() - - // Make unhealthy - healthy = false - - resp, err = http.Get("http://" + addr + "/health") - require.NoError(t, err) - assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) - _ = resp.Body.Close() - }) -} - -func TestDaemon(t *testing.T) { - t.Run("start and stop", func(t *testing.T) { - m := io.NewMockMedium() - pidPath := "/tmp/test.pid" - - d := NewDaemon(DaemonOptions{ - Medium: m, - PIDFile: pidPath, - HealthAddr: "127.0.0.1:0", - ShutdownTimeout: 5 * time.Second, - }) - - err := d.Start() - require.NoError(t, err) - - // Health server should be running - addr := d.HealthAddr() - require.NotEmpty(t, addr) - - resp, err := http.Get("http://" + addr + "/health") - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) - _ = resp.Body.Close() - - // Stop should succeed - err = d.Stop() - require.NoError(t, err) - - // PID file should be removed - assert.False(t, m.Exists(pidPath)) - }) - - t.Run("double start fails", func(t *testing.T) { - d := NewDaemon(DaemonOptions{ - HealthAddr: "127.0.0.1:0", - }) - - err := d.Start() - require.NoError(t, err) - defer func() { _ = d.Stop() }() - - err = d.Start() - assert.Error(t, err) - assert.Contains(t, err.Error(), "already running") - }) - - t.Run("run without start fails", func(t *testing.T) { - d := NewDaemon(DaemonOptions{}) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - err := d.Run(ctx) - assert.Error(t, err) - assert.Contains(t, err.Error(), "not started") - }) - - t.Run("set ready", func(t *testing.T) { - d := NewDaemon(DaemonOptions{ - HealthAddr: "127.0.0.1:0", - }) - - err := d.Start() - require.NoError(t, err) - defer func() { _ = d.Stop() }() - - addr := d.HealthAddr() - - // Initially ready - resp, _ := http.Get("http://" + addr + "/ready") - assert.Equal(t, http.StatusOK, resp.StatusCode) - _ = resp.Body.Close() - - // Set not ready - d.SetReady(false) - - resp, _ = http.Get("http://" + addr + "/ready") - assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) - _ = resp.Body.Close() - }) - - t.Run("no health addr returns empty", func(t *testing.T) { - d := NewDaemon(DaemonOptions{}) - assert.Empty(t, d.HealthAddr()) - }) - - t.Run("default shutdown timeout", func(t *testing.T) { - d := NewDaemon(DaemonOptions{}) - assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout) - }) -} - -func TestRunWithTimeout(t *testing.T) { - t.Run("creates shutdown function", func(t *testing.T) { - // Just test that it returns a function - shutdown := RunWithTimeout(100 * time.Millisecond) - assert.NotNil(t, shutdown) - }) -} diff --git a/pkg/cli/errors.go b/pkg/cli/errors.go deleted file mode 100644 index e74982c..0000000 --- a/pkg/cli/errors.go +++ /dev/null @@ -1,162 +0,0 @@ -package cli - -import ( - "errors" - "fmt" - "os" - - "forge.lthn.ai/core/go/pkg/i18n" -) - -// ───────────────────────────────────────────────────────────────────────────── -// Error Creation (replace fmt.Errorf) -// ───────────────────────────────────────────────────────────────────────────── - -// Err creates a new error from a format string. -// This is a direct replacement for fmt.Errorf. -func Err(format string, args ...any) error { - return fmt.Errorf(format, args...) -} - -// Wrap wraps an error with a message. -// Returns nil if err is nil. -// -// return cli.Wrap(err, "load config") // "load config: " -func Wrap(err error, msg string) error { - if err == nil { - return nil - } - return fmt.Errorf("%s: %w", msg, err) -} - -// WrapVerb wraps an error using i18n grammar for "Failed to verb subject". -// Uses the i18n.ActionFailed function for proper grammar composition. -// Returns nil if err is nil. -// -// return cli.WrapVerb(err, "load", "config") // "Failed to load config: " -func WrapVerb(err error, verb, subject string) error { - if err == nil { - return nil - } - msg := i18n.ActionFailed(verb, subject) - return fmt.Errorf("%s: %w", msg, err) -} - -// WrapAction wraps an error using i18n grammar for "Failed to verb". -// Uses the i18n.ActionFailed function for proper grammar composition. -// Returns nil if err is nil. -// -// return cli.WrapAction(err, "connect") // "Failed to connect: " -func WrapAction(err error, verb string) error { - if err == nil { - return nil - } - msg := i18n.ActionFailed(verb, "") - return fmt.Errorf("%s: %w", msg, err) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Error Helpers -// ───────────────────────────────────────────────────────────────────────────── - -// Is reports whether any error in err's tree matches target. -// This is a re-export of errors.Is for convenience. -func Is(err, target error) bool { - return errors.Is(err, target) -} - -// As finds the first error in err's tree that matches target. -// This is a re-export of errors.As for convenience. -func As(err error, target any) bool { - return errors.As(err, target) -} - -// Join returns an error that wraps the given errors. -// This is a re-export of errors.Join for convenience. -func Join(errs ...error) error { - return errors.Join(errs...) -} - -// ExitError represents an error that should cause the CLI to exit with a specific code. -type ExitError struct { - Code int - Err error -} - -func (e *ExitError) Error() string { - if e.Err == nil { - return "" - } - return e.Err.Error() -} - -func (e *ExitError) Unwrap() error { - return e.Err -} - -// Exit creates a new ExitError with the given code and error. -// Use this to return an error from a command with a specific exit code. -func Exit(code int, err error) error { - if err == nil { - return nil - } - return &ExitError{Code: code, Err: err} -} - -// ───────────────────────────────────────────────────────────────────────────── -// Fatal Functions (Deprecated - return error from command instead) -// ───────────────────────────────────────────────────────────────────────────── - -// Fatal prints an error message to stderr, logs it, and exits with code 1. -// -// Deprecated: return an error from the command instead. -func Fatal(err error) { - if err != nil { - LogError("Fatal error", "err", err) - fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+err.Error())) - os.Exit(1) - } -} - -// Fatalf prints a formatted error message to stderr, logs it, and exits with code 1. -// -// Deprecated: return an error from the command instead. -func Fatalf(format string, args ...any) { - msg := fmt.Sprintf(format, args...) - LogError("Fatal error", "msg", msg) - fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg)) - os.Exit(1) -} - -// FatalWrap prints a wrapped error message to stderr, logs it, and exits with code 1. -// Does nothing if err is nil. -// -// Deprecated: return an error from the command instead. -// -// cli.FatalWrap(err, "load config") // Prints "✗ load config: " and exits -func FatalWrap(err error, msg string) { - if err == nil { - return - } - LogError("Fatal error", "msg", msg, "err", err) - fullMsg := fmt.Sprintf("%s: %v", msg, err) - fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg)) - os.Exit(1) -} - -// FatalWrapVerb prints a wrapped error using i18n grammar to stderr, logs it, and exits with code 1. -// Does nothing if err is nil. -// -// Deprecated: return an error from the command instead. -// -// cli.FatalWrapVerb(err, "load", "config") // Prints "✗ Failed to load config: " and exits -func FatalWrapVerb(err error, verb, subject string) { - if err == nil { - return - } - msg := i18n.ActionFailed(verb, subject) - LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject) - fullMsg := fmt.Sprintf("%s: %v", msg, err) - fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg)) - os.Exit(1) -} diff --git a/pkg/cli/glyph.go b/pkg/cli/glyph.go deleted file mode 100644 index 26023e5..0000000 --- a/pkg/cli/glyph.go +++ /dev/null @@ -1,92 +0,0 @@ -package cli - -import ( - "bytes" - "unicode" -) - -// GlyphTheme defines which symbols to use. -type GlyphTheme int - -const ( - // ThemeUnicode uses standard Unicode symbols. - ThemeUnicode GlyphTheme = iota - // ThemeEmoji uses Emoji symbols. - ThemeEmoji - // ThemeASCII uses ASCII fallback symbols. - ThemeASCII -) - -var currentTheme = ThemeUnicode - -// UseUnicode switches the glyph theme to Unicode. -func UseUnicode() { currentTheme = ThemeUnicode } - -// UseEmoji switches the glyph theme to Emoji. -func UseEmoji() { currentTheme = ThemeEmoji } - -// UseASCII switches the glyph theme to ASCII and disables colors. -func UseASCII() { - currentTheme = ThemeASCII - SetColorEnabled(false) -} - -func glyphMap() map[string]string { - switch currentTheme { - case ThemeEmoji: - return glyphMapEmoji - case ThemeASCII: - return glyphMapASCII - default: - return glyphMapUnicode - } -} - -// Glyph converts a shortcode (e.g. ":check:") to its symbol based on the current theme. -func Glyph(code string) string { - if sym, ok := glyphMap()[code]; ok { - return sym - } - return code -} - -func compileGlyphs(x string) string { - if x == "" { - return "" - } - input := bytes.NewBufferString(x) - output := bytes.NewBufferString("") - - for { - r, _, err := input.ReadRune() - if err != nil { - break - } - if r == ':' { - output.WriteString(replaceGlyph(input)) - } else { - output.WriteRune(r) - } - } - return output.String() -} - -func replaceGlyph(input *bytes.Buffer) string { - code := bytes.NewBufferString(":") - for { - r, _, err := input.ReadRune() - if err != nil { - return code.String() - } - if r == ':' && code.Len() == 1 { - return code.String() + replaceGlyph(input) - } - code.WriteRune(r) - if unicode.IsSpace(r) { - return code.String() - } - if r == ':' { - return Glyph(code.String()) - } - } -} diff --git a/pkg/cli/glyph_maps.go b/pkg/cli/glyph_maps.go deleted file mode 100644 index 0aed5b8..0000000 --- a/pkg/cli/glyph_maps.go +++ /dev/null @@ -1,25 +0,0 @@ -package cli - -var glyphMapUnicode = map[string]string{ - ":check:": "✓", ":cross:": "✗", ":warn:": "⚠", ":info:": "ℹ", - ":question:": "?", ":skip:": "○", ":dot:": "●", ":circle:": "◯", - ":arrow_right:": "→", ":arrow_left:": "←", ":arrow_up:": "↑", ":arrow_down:": "↓", - ":pointer:": "▶", ":bullet:": "•", ":dash:": "─", ":pipe:": "│", - ":corner:": "└", ":tee:": "├", ":pending:": "…", ":spinner:": "⠋", -} - -var glyphMapEmoji = map[string]string{ - ":check:": "✅", ":cross:": "❌", ":warn:": "⚠️", ":info:": "ℹ️", - ":question:": "❓", ":skip:": "⏭️", ":dot:": "🔵", ":circle:": "⚪", - ":arrow_right:": "➡️", ":arrow_left:": "⬅️", ":arrow_up:": "⬆️", ":arrow_down:": "⬇️", - ":pointer:": "▶️", ":bullet:": "•", ":dash:": "─", ":pipe:": "│", - ":corner:": "└", ":tee:": "├", ":pending:": "⏳", ":spinner:": "🔄", -} - -var glyphMapASCII = map[string]string{ - ":check:": "[OK]", ":cross:": "[FAIL]", ":warn:": "[WARN]", ":info:": "[INFO]", - ":question:": "[?]", ":skip:": "[SKIP]", ":dot:": "[*]", ":circle:": "[ ]", - ":arrow_right:": "->", ":arrow_left:": "<-", ":arrow_up:": "^", ":arrow_down:": "v", - ":pointer:": ">", ":bullet:": "*", ":dash:": "-", ":pipe:": "|", - ":corner:": "`", ":tee:": "+", ":pending:": "...", ":spinner:": "-", -} diff --git a/pkg/cli/glyph_test.go b/pkg/cli/glyph_test.go deleted file mode 100644 index d43c0be..0000000 --- a/pkg/cli/glyph_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package cli - -import "testing" - -func TestGlyph(t *testing.T) { - UseUnicode() - if Glyph(":check:") != "✓" { - t.Errorf("Expected ✓, got %s", Glyph(":check:")) - } - - UseASCII() - if Glyph(":check:") != "[OK]" { - t.Errorf("Expected [OK], got %s", Glyph(":check:")) - } -} - -func TestCompileGlyphs(t *testing.T) { - UseUnicode() - got := compileGlyphs("Status: :check:") - if got != "Status: ✓" { - t.Errorf("Expected Status: ✓, got %s", got) - } -} diff --git a/pkg/cli/i18n.go b/pkg/cli/i18n.go deleted file mode 100644 index 29983fa..0000000 --- a/pkg/cli/i18n.go +++ /dev/null @@ -1,170 +0,0 @@ -package cli - -import ( - "context" - "sync" - - "forge.lthn.ai/core/go/pkg/framework" - "forge.lthn.ai/core/go/pkg/i18n" -) - -// I18nService wraps i18n as a Core service. -type I18nService struct { - *framework.ServiceRuntime[I18nOptions] - svc *i18n.Service - - // Collect mode state - missingKeys []i18n.MissingKey - missingKeysMu sync.Mutex -} - -// I18nOptions configures the i18n service. -type I18nOptions struct { - // Language overrides auto-detection (e.g., "en-GB", "de") - Language string - // Mode sets the translation mode (Normal, Strict, Collect) - Mode i18n.Mode -} - -// 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) - } - - // Set mode if specified - svc.SetMode(opts.Mode) - - // Set as global default so i18n.T() works everywhere - i18n.SetDefault(svc) - - return &I18nService{ - ServiceRuntime: framework.NewServiceRuntime(c, opts), - svc: svc, - missingKeys: make([]i18n.MissingKey, 0), - }, nil - } -} - -// OnStartup initialises the i18n service. -func (s *I18nService) OnStartup(ctx context.Context) error { - s.Core().RegisterQuery(s.handleQuery) - - // Register action handler for collect mode - if s.svc.Mode() == i18n.ModeCollect { - i18n.OnMissingKey(s.handleMissingKey) - } - - return nil -} - -// handleMissingKey accumulates missing keys in collect mode. -func (s *I18nService) handleMissingKey(mk i18n.MissingKey) { - s.missingKeysMu.Lock() - defer s.missingKeysMu.Unlock() - s.missingKeys = append(s.missingKeys, mk) -} - -// MissingKeys returns all missing keys collected in collect mode. -// Call this at the end of a QA session to report missing translations. -func (s *I18nService) MissingKeys() []i18n.MissingKey { - s.missingKeysMu.Lock() - defer s.missingKeysMu.Unlock() - result := make([]i18n.MissingKey, len(s.missingKeys)) - copy(result, s.missingKeys) - return result -} - -// ClearMissingKeys resets the collected missing keys. -func (s *I18nService) ClearMissingKeys() { - s.missingKeysMu.Lock() - defer s.missingKeysMu.Unlock() - s.missingKeys = s.missingKeys[:0] -} - -// SetMode changes the translation mode. -func (s *I18nService) SetMode(mode i18n.Mode) { - s.svc.SetMode(mode) - - // Update action handler registration - if mode == i18n.ModeCollect { - i18n.OnMissingKey(s.handleMissingKey) - } else { - i18n.OnMissingKey(nil) - } -} - -// Mode returns the current translation mode. -func (s *I18nService) Mode() i18n.Mode { - return s.svc.Mode() -} - -// 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...) -} diff --git a/pkg/cli/layout.go b/pkg/cli/layout.go deleted file mode 100644 index a8aedbb..0000000 --- a/pkg/cli/layout.go +++ /dev/null @@ -1,148 +0,0 @@ -package cli - -import "fmt" - -// Region represents one of the 5 HLCRF regions. -type Region rune - -const ( - // RegionHeader is the top region of the layout. - RegionHeader Region = 'H' - // RegionLeft is the left sidebar region. - RegionLeft Region = 'L' - // RegionContent is the main content region. - RegionContent Region = 'C' - // RegionRight is the right sidebar region. - RegionRight Region = 'R' - // RegionFooter is the bottom region of the layout. - RegionFooter Region = 'F' -) - -// Composite represents an HLCRF layout node. -type Composite struct { - variant string - path string - regions map[Region]*Slot - parent *Composite -} - -// Slot holds content for a region. -type Slot struct { - region Region - path string - blocks []Renderable - child *Composite -} - -// Renderable is anything that can be rendered to terminal. -type Renderable interface { - Render() string -} - -// StringBlock is a simple string that implements Renderable. -type StringBlock string - -// Render returns the string content. -func (s StringBlock) Render() string { return string(s) } - -// Layout creates a new layout from a variant string. -func Layout(variant string) *Composite { - c, err := ParseVariant(variant) - if err != nil { - return &Composite{variant: variant, regions: make(map[Region]*Slot)} - } - return c -} - -// ParseVariant parses a variant string like "H[LC]C[HCF]F". -func ParseVariant(variant string) (*Composite, error) { - c := &Composite{ - variant: variant, - path: "", - regions: make(map[Region]*Slot), - } - - i := 0 - for i < len(variant) { - r := Region(variant[i]) - if !isValidRegion(r) { - return nil, fmt.Errorf("invalid region: %c", r) - } - - slot := &Slot{region: r, path: string(r)} - c.regions[r] = slot - i++ - - if i < len(variant) && variant[i] == '[' { - end := findMatchingBracket(variant, i) - if end == -1 { - return nil, fmt.Errorf("unmatched bracket at %d", i) - } - nested, err := ParseVariant(variant[i+1 : end]) - if err != nil { - return nil, err - } - nested.path = string(r) + "-" - nested.parent = c - slot.child = nested - i = end + 1 - } - } - return c, nil -} - -func isValidRegion(r Region) bool { - return r == 'H' || r == 'L' || r == 'C' || r == 'R' || r == 'F' -} - -func findMatchingBracket(s string, start int) int { - depth := 0 - for i := start; i < len(s); i++ { - switch s[i] { - case '[': - depth++ - case ']': - depth-- - if depth == 0 { - return i - } - } - } - return -1 -} - -// H adds content to Header region. -func (c *Composite) H(items ...any) *Composite { c.addToRegion(RegionHeader, items...); return c } - -// L adds content to Left region. -func (c *Composite) L(items ...any) *Composite { c.addToRegion(RegionLeft, items...); return c } - -// C adds content to Content region. -func (c *Composite) C(items ...any) *Composite { c.addToRegion(RegionContent, items...); return c } - -// R adds content to Right region. -func (c *Composite) R(items ...any) *Composite { c.addToRegion(RegionRight, items...); return c } - -// F adds content to Footer region. -func (c *Composite) F(items ...any) *Composite { c.addToRegion(RegionFooter, items...); return c } - -func (c *Composite) addToRegion(r Region, items ...any) { - slot, ok := c.regions[r] - if !ok { - return - } - for _, item := range items { - slot.blocks = append(slot.blocks, toRenderable(item)) - } -} - -func toRenderable(item any) Renderable { - switch v := item.(type) { - case Renderable: - return v - case string: - return StringBlock(v) - default: - return StringBlock(fmt.Sprint(v)) - } -} diff --git a/pkg/cli/layout_test.go b/pkg/cli/layout_test.go deleted file mode 100644 index 4fb42ad..0000000 --- a/pkg/cli/layout_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package cli - -import "testing" - -func TestParseVariant(t *testing.T) { - c, err := ParseVariant("H[LC]F") - if err != nil { - t.Fatalf("Parse failed: %v", err) - } - if _, ok := c.regions[RegionHeader]; !ok { - t.Error("Expected Header region") - } - if _, ok := c.regions[RegionFooter]; !ok { - t.Error("Expected Footer region") - } - - hSlot := c.regions[RegionHeader] - if hSlot.child == nil { - t.Error("Header should have child layout") - } else { - if _, ok := hSlot.child.regions[RegionLeft]; !ok { - t.Error("Child should have Left region") - } - } -} diff --git a/pkg/cli/log.go b/pkg/cli/log.go deleted file mode 100644 index 893df2e..0000000 --- a/pkg/cli/log.go +++ /dev/null @@ -1,115 +0,0 @@ -package cli - -import ( - "forge.lthn.ai/core/go/pkg/framework" - "forge.lthn.ai/core/go/pkg/log" -) - -// LogLevel aliases for backwards compatibility. -type LogLevel = log.Level - -// Log level constants aliased from the log package. -const ( - // LogLevelQuiet suppresses all output. - LogLevelQuiet = log.LevelQuiet - // LogLevelError shows only error messages. - LogLevelError = log.LevelError - // LogLevelWarn shows warnings and errors. - LogLevelWarn = log.LevelWarn - // LogLevelInfo shows info, warnings, and errors. - LogLevelInfo = log.LevelInfo - // LogLevelDebug shows all messages including debug. - LogLevelDebug = log.LevelDebug -) - -// LogService wraps log.Service with CLI styling. -type LogService struct { - *log.Service -} - -// LogOptions configures the log service. -type LogOptions = log.Options - -// NewLogService creates a log service factory with CLI styling. -func NewLogService(opts LogOptions) func(*framework.Core) (any, error) { - return func(c *framework.Core) (any, error) { - // Create the underlying service - factory := log.NewService(opts) - svc, err := factory(c) - if err != nil { - return nil, err - } - - logSvc := svc.(*log.Service) - - // Apply CLI styles - logSvc.StyleTimestamp = func(s string) string { return DimStyle.Render(s) } - logSvc.StyleDebug = func(s string) string { return DimStyle.Render(s) } - logSvc.StyleInfo = func(s string) string { return InfoStyle.Render(s) } - logSvc.StyleWarn = func(s string) string { return WarningStyle.Render(s) } - logSvc.StyleError = func(s string) string { return ErrorStyle.Render(s) } - logSvc.StyleSecurity = func(s string) string { return SecurityStyle.Render(s) } - - return &LogService{Service: logSvc}, nil - } -} - -// --- 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 with optional key-value pairs if log service is available. -func LogDebug(msg string, keyvals ...any) { - if l := Log(); l != nil { - l.Debug(msg, keyvals...) - } -} - -// LogInfo logs an info message with optional key-value pairs if log service is available. -func LogInfo(msg string, keyvals ...any) { - if l := Log(); l != nil { - l.Info(msg, keyvals...) - } -} - -// LogWarn logs a warning message with optional key-value pairs if log service is available. -func LogWarn(msg string, keyvals ...any) { - if l := Log(); l != nil { - l.Warn(msg, keyvals...) - } -} - -// LogError logs an error message with optional key-value pairs if log service is available. -func LogError(msg string, keyvals ...any) { - if l := Log(); l != nil { - l.Error(msg, keyvals...) - } -} - -// LogSecurity logs a security message if log service is available. -func LogSecurity(msg string, keyvals ...any) { - if l := Log(); l != nil { - // Ensure user context is included if not already present - hasUser := false - for i := 0; i < len(keyvals); i += 2 { - if keyvals[i] == "user" { - hasUser = true - break - } - } - if !hasUser { - keyvals = append(keyvals, "user", log.Username()) - } - l.Security(msg, keyvals...) - } -} diff --git a/pkg/cli/output.go b/pkg/cli/output.go deleted file mode 100644 index 3e1662f..0000000 --- a/pkg/cli/output.go +++ /dev/null @@ -1,195 +0,0 @@ -package cli - -import ( - "fmt" - "os" - "strings" - - "forge.lthn.ai/core/go/pkg/i18n" -) - -// Blank prints an empty line. -func Blank() { - fmt.Println() -} - -// Echo translates a key via i18n.T and prints with newline. -// No automatic styling - use Success/Error/Warn/Info for styled output. -func Echo(key string, args ...any) { - fmt.Println(i18n.T(key, args...)) -} - -// Print outputs formatted text (no newline). -// Glyph shortcodes like :check: are converted. -func Print(format string, args ...any) { - fmt.Print(compileGlyphs(fmt.Sprintf(format, args...))) -} - -// Println outputs formatted text with newline. -// Glyph shortcodes like :check: are converted. -func Println(format string, args ...any) { - fmt.Println(compileGlyphs(fmt.Sprintf(format, args...))) -} - -// Text prints arguments like fmt.Println, but handling glyphs. -func Text(args ...any) { - fmt.Println(compileGlyphs(fmt.Sprint(args...))) -} - -// Success prints a success message with checkmark (green). -func Success(msg string) { - fmt.Println(SuccessStyle.Render(Glyph(":check:") + " " + msg)) -} - -// Successf prints a formatted success message. -func Successf(format string, args ...any) { - Success(fmt.Sprintf(format, args...)) -} - -// Error prints an error message with cross (red) to stderr and logs it. -func Error(msg string) { - LogError(msg) - fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg)) -} - -// Errorf prints a formatted error message to stderr and logs it. -func Errorf(format string, args ...any) { - Error(fmt.Sprintf(format, args...)) -} - -// ErrorWrap prints a wrapped error message to stderr and logs it. -func ErrorWrap(err error, msg string) { - if err == nil { - return - } - Error(fmt.Sprintf("%s: %v", msg, err)) -} - -// ErrorWrapVerb prints a wrapped error using i18n grammar to stderr and logs it. -func ErrorWrapVerb(err error, verb, subject string) { - if err == nil { - return - } - msg := i18n.ActionFailed(verb, subject) - Error(fmt.Sprintf("%s: %v", msg, err)) -} - -// ErrorWrapAction prints a wrapped error using i18n grammar to stderr and logs it. -func ErrorWrapAction(err error, verb string) { - if err == nil { - return - } - msg := i18n.ActionFailed(verb, "") - Error(fmt.Sprintf("%s: %v", msg, err)) -} - -// Warn prints a warning message with warning symbol (amber) to stderr and logs it. -func Warn(msg string) { - LogWarn(msg) - fmt.Fprintln(os.Stderr, WarningStyle.Render(Glyph(":warn:")+" "+msg)) -} - -// Warnf prints a formatted warning message to stderr and logs it. -func Warnf(format string, args ...any) { - Warn(fmt.Sprintf(format, args...)) -} - -// Info prints an info message with info symbol (blue). -func Info(msg string) { - fmt.Println(InfoStyle.Render(Glyph(":info:") + " " + msg)) -} - -// Infof prints a formatted info message. -func Infof(format string, args ...any) { - Info(fmt.Sprintf(format, args...)) -} - -// Dim prints dimmed text. -func Dim(msg string) { - fmt.Println(DimStyle.Render(msg)) -} - -// Progress prints a progress indicator that overwrites the current line. -// Uses i18n.Progress for gerund form ("Checking..."). -func Progress(verb string, current, total int, item ...string) { - msg := i18n.Progress(verb) - if len(item) > 0 && item[0] != "" { - fmt.Printf("\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, item[0]) - } else { - fmt.Printf("\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total) - } -} - -// ProgressDone clears the progress line. -func ProgressDone() { - fmt.Print("\033[2K\r") -} - -// Label prints a "Label: value" line. -func Label(word, value string) { - fmt.Printf("%s %s\n", KeyStyle.Render(i18n.Label(word)), value) -} - -// Scanln reads from stdin. -func Scanln(a ...any) (int, error) { - return fmt.Scanln(a...) -} - -// Task prints a task header: "[label] message" -// -// cli.Task("php", "Running tests...") // [php] Running tests... -// cli.Task("go", i18n.Progress("build")) // [go] Building... -func Task(label, message string) { - fmt.Printf("%s %s\n\n", DimStyle.Render("["+label+"]"), message) -} - -// Section prints a section header: "── SECTION ──" -// -// cli.Section("audit") // ── AUDIT ── -func Section(name string) { - header := "── " + strings.ToUpper(name) + " ──" - fmt.Println(AccentStyle.Render(header)) -} - -// Hint prints a labelled hint: "label: message" -// -// cli.Hint("install", "composer require vimeo/psalm") -// cli.Hint("fix", "core php fmt --fix") -func Hint(label, message string) { - fmt.Printf(" %s %s\n", DimStyle.Render(label+":"), message) -} - -// Severity prints a severity-styled message. -// -// cli.Severity("critical", "SQL injection") // red, bold -// cli.Severity("high", "XSS vulnerability") // orange -// cli.Severity("medium", "Missing CSRF") // amber -// cli.Severity("low", "Debug enabled") // gray -func Severity(level, message string) { - var style *AnsiStyle - switch strings.ToLower(level) { - case "critical": - style = NewStyle().Bold().Foreground(ColourRed500) - case "high": - style = NewStyle().Bold().Foreground(ColourOrange500) - case "medium": - style = NewStyle().Foreground(ColourAmber500) - case "low": - style = NewStyle().Foreground(ColourGray500) - default: - style = DimStyle - } - fmt.Printf(" %s %s\n", style.Render("["+level+"]"), message) -} - -// Result prints a result line: "✓ message" or "✗ message" -// -// cli.Result(passed, "All tests passed") -// cli.Result(false, "3 tests failed") -func Result(passed bool, message string) { - if passed { - Success(message) - } else { - Error(message) - } -} diff --git a/pkg/cli/output_test.go b/pkg/cli/output_test.go deleted file mode 100644 index 91a92ec..0000000 --- a/pkg/cli/output_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package cli - -import ( - "bytes" - "io" - "os" - "testing" -) - -func captureOutput(f func()) string { - oldOut := os.Stdout - oldErr := os.Stderr - r, w, _ := os.Pipe() - os.Stdout = w - os.Stderr = w - - f() - - _ = w.Close() - os.Stdout = oldOut - os.Stderr = oldErr - - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - return buf.String() -} - -func TestSemanticOutput(t *testing.T) { - UseASCII() - - // Test Success - out := captureOutput(func() { - Success("done") - }) - if out == "" { - t.Error("Success output empty") - } - - // Test Error - out = captureOutput(func() { - Error("fail") - }) - if out == "" { - t.Error("Error output empty") - } - - // Test Warn - out = captureOutput(func() { - Warn("warn") - }) - if out == "" { - t.Error("Warn output empty") - } - - // Test Info - out = captureOutput(func() { - Info("info") - }) - if out == "" { - t.Error("Info output empty") - } - - // Test Task - out = captureOutput(func() { - Task("task", "msg") - }) - if out == "" { - t.Error("Task output empty") - } - - // Test Section - out = captureOutput(func() { - Section("section") - }) - if out == "" { - t.Error("Section output empty") - } - - // Test Hint - out = captureOutput(func() { - Hint("hint", "msg") - }) - if out == "" { - t.Error("Hint output empty") - } - - // Test Result - out = captureOutput(func() { - Result(true, "pass") - }) - if out == "" { - t.Error("Result(true) output empty") - } - - out = captureOutput(func() { - Result(false, "fail") - }) - if out == "" { - t.Error("Result(false) output empty") - } -} diff --git a/pkg/cli/prompt.go b/pkg/cli/prompt.go deleted file mode 100644 index d9eb993..0000000 --- a/pkg/cli/prompt.go +++ /dev/null @@ -1,75 +0,0 @@ -package cli - -import ( - "bufio" - "fmt" - "os" - "strconv" - "strings" -) - -var stdin = bufio.NewReader(os.Stdin) - -// Prompt asks for text input with a default value. -func Prompt(label, defaultVal string) (string, error) { - if defaultVal != "" { - fmt.Printf("%s [%s]: ", label, defaultVal) - } else { - fmt.Printf("%s: ", label) - } - - input, err := stdin.ReadString('\n') - if err != nil { - return "", err - } - - input = strings.TrimSpace(input) - if input == "" { - return defaultVal, nil - } - return input, nil -} - -// Select presents numbered options and returns the selected value. -func Select(label string, options []string) (string, error) { - fmt.Println(label) - for i, opt := range options { - fmt.Printf(" %d. %s\n", i+1, opt) - } - fmt.Printf("Choose [1-%d]: ", len(options)) - - input, err := stdin.ReadString('\n') - if err != nil { - return "", err - } - - n, err := strconv.Atoi(strings.TrimSpace(input)) - if err != nil || n < 1 || n > len(options) { - return "", fmt.Errorf("invalid selection") - } - return options[n-1], nil -} - -// MultiSelect presents checkboxes (space-separated numbers). -func MultiSelect(label string, options []string) ([]string, error) { - fmt.Println(label) - for i, opt := range options { - fmt.Printf(" %d. %s\n", i+1, opt) - } - fmt.Printf("Choose (space-separated) [1-%d]: ", len(options)) - - input, err := stdin.ReadString('\n') - if err != nil { - return nil, err - } - - var selected []string - for _, s := range strings.Fields(input) { - n, err := strconv.Atoi(s) - if err != nil || n < 1 || n > len(options) { - continue - } - selected = append(selected, options[n-1]) - } - return selected, nil -} diff --git a/pkg/cli/render.go b/pkg/cli/render.go deleted file mode 100644 index 95bb05c..0000000 --- a/pkg/cli/render.go +++ /dev/null @@ -1,87 +0,0 @@ -package cli - -import ( - "fmt" - "strings" -) - -// RenderStyle controls how layouts are rendered. -type RenderStyle int - -// Render style constants for layout output. -const ( - // RenderFlat uses no borders or decorations. - RenderFlat RenderStyle = iota - // RenderSimple uses --- separators between sections. - RenderSimple - // RenderBoxed uses Unicode box drawing characters. - RenderBoxed -) - -var currentRenderStyle = RenderFlat - -// UseRenderFlat sets the render style to flat (no borders). -func UseRenderFlat() { currentRenderStyle = RenderFlat } - -// UseRenderSimple sets the render style to simple (--- separators). -func UseRenderSimple() { currentRenderStyle = RenderSimple } - -// UseRenderBoxed sets the render style to boxed (Unicode box drawing). -func UseRenderBoxed() { currentRenderStyle = RenderBoxed } - -// Render outputs the layout to terminal. -func (c *Composite) Render() { - fmt.Print(c.String()) -} - -// String returns the rendered layout. -func (c *Composite) String() string { - var sb strings.Builder - c.renderTo(&sb, 0) - return sb.String() -} - -func (c *Composite) renderTo(sb *strings.Builder, depth int) { - order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter} - - var active []Region - for _, r := range order { - if slot, ok := c.regions[r]; ok { - if len(slot.blocks) > 0 || slot.child != nil { - active = append(active, r) - } - } - } - - for i, r := range active { - slot := c.regions[r] - if i > 0 && currentRenderStyle != RenderFlat { - c.renderSeparator(sb, depth) - } - c.renderSlot(sb, slot, depth) - } -} - -func (c *Composite) renderSeparator(sb *strings.Builder, depth int) { - indent := strings.Repeat(" ", depth) - switch currentRenderStyle { - case RenderBoxed: - sb.WriteString(indent + "├" + strings.Repeat("─", 40) + "┤\n") - case RenderSimple: - sb.WriteString(indent + strings.Repeat("─", 40) + "\n") - } -} - -func (c *Composite) renderSlot(sb *strings.Builder, slot *Slot, depth int) { - indent := strings.Repeat(" ", depth) - for _, block := range slot.blocks { - for _, line := range strings.Split(block.Render(), "\n") { - if line != "" { - sb.WriteString(indent + line + "\n") - } - } - } - if slot.child != nil { - slot.child.renderTo(sb, depth+1) - } -} diff --git a/pkg/cli/runtime.go b/pkg/cli/runtime.go deleted file mode 100644 index 08636f1..0000000 --- a/pkg/cli/runtime.go +++ /dev/null @@ -1,219 +0,0 @@ -// Package cli provides the CLI runtime and utilities. -// -// The CLI uses the Core framework for its own runtime. Usage is simple: -// -// cli.Init(cli.Options{AppName: "core"}) -// defer cli.Shutdown() -// -// cli.Success("Done!") -// cli.Error("Failed") -// if cli.Confirm("Proceed?") { ... } -// -// // When you need the Core instance -// c := cli.Core() -package cli - -import ( - "context" - "os" - "os/signal" - "sync" - "syscall" - - "forge.lthn.ai/core/go/pkg/framework" - "github.com/spf13/cobra" -) - -var ( - instance *runtime - once sync.Once -) - -// runtime is the CLI's internal Core runtime. -type runtime struct { - core *framework.Core - root *cobra.Command - ctx context.Context - cancel context.CancelFunc -} - -// Options configures the CLI runtime. -type Options struct { - AppName string - Version string - Services []framework.Option // Additional services to register - - // OnReload is called when SIGHUP is received (daemon mode). - // Use for configuration reloading. Leave nil to ignore SIGHUP. - OnReload func() error -} - -// Init initialises the global CLI runtime. -// 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()) - - // Create root command - rootCmd := &cobra.Command{ - Use: opts.AppName, - Version: opts.Version, - SilenceErrors: true, - SilenceUsage: true, - } - - // Attach all registered commands - attachRegisteredCommands(rootCmd) - - // Build signal service options - var signalOpts []SignalOption - if opts.OnReload != nil { - signalOpts = append(signalOpts, WithReloadHandler(opts.OnReload)) - } - - // Build options: app, signal service + any additional services - coreOpts := []framework.Option{ - framework.WithApp(rootCmd), - framework.WithName("signal", newSignalService(cancel, signalOpts...)), - } - coreOpts = append(coreOpts, opts.Services...) - coreOpts = append(coreOpts, framework.WithServiceLock()) - - c, err := framework.New(coreOpts...) - if err != nil { - initErr = err - cancel() - return - } - - instance = &runtime{ - core: c, - root: rootCmd, - ctx: ctx, - cancel: cancel, - } - - if err := c.ServiceStartup(ctx, nil); err != nil { - initErr = err - return - } - }) - return initErr -} - -func mustInit() { - if instance == nil { - panic("cli not initialised - call cli.Init() first") - } -} - -// --- Core Access --- - -// Core returns the CLI's framework Core instance. -func Core() *framework.Core { - mustInit() - return instance.core -} - -// RootCmd returns the CLI's root cobra command. -func RootCmd() *cobra.Command { - mustInit() - return instance.root -} - -// Execute runs the CLI root command. -// Returns an error if the command fails. -func Execute() error { - mustInit() - return instance.root.Execute() -} - -// Context returns the CLI's root context. -// Cancelled on SIGINT/SIGTERM. -func Context() context.Context { - mustInit() - return instance.ctx -} - -// Shutdown gracefully shuts down the CLI. -func Shutdown() { - if instance == nil { - return - } - instance.cancel() - _ = instance.core.ServiceShutdown(instance.ctx) -} - -// --- Signal Service (internal) --- - -type signalService struct { - cancel context.CancelFunc - sigChan chan os.Signal - onReload func() error - shutdownOnce sync.Once -} - -// SignalOption configures signal handling. -type SignalOption func(*signalService) - -// WithReloadHandler sets a callback for SIGHUP. -func WithReloadHandler(fn func() error) SignalOption { - return func(s *signalService) { - s.onReload = fn - } -} - -func newSignalService(cancel context.CancelFunc, opts ...SignalOption) func(*framework.Core) (any, error) { - return func(c *framework.Core) (any, error) { - svc := &signalService{ - cancel: cancel, - sigChan: make(chan os.Signal, 1), - } - for _, opt := range opts { - opt(svc) - } - return svc, nil - } -} - -func (s *signalService) OnStartup(ctx context.Context) error { - signals := []os.Signal{syscall.SIGINT, syscall.SIGTERM} - if s.onReload != nil { - signals = append(signals, syscall.SIGHUP) - } - signal.Notify(s.sigChan, signals...) - - go func() { - for { - select { - case sig := <-s.sigChan: - switch sig { - case syscall.SIGHUP: - if s.onReload != nil { - if err := s.onReload(); err != nil { - LogError("reload failed", "err", err) - } else { - LogInfo("configuration reloaded") - } - } - case syscall.SIGINT, syscall.SIGTERM: - s.cancel() - return - } - case <-ctx.Done(): - return - } - } - }() - - return nil -} - -func (s *signalService) OnShutdown(ctx context.Context) error { - s.shutdownOnce.Do(func() { - signal.Stop(s.sigChan) - close(s.sigChan) - }) - return nil -} diff --git a/pkg/cli/strings.go b/pkg/cli/strings.go deleted file mode 100644 index 1e587ad..0000000 --- a/pkg/cli/strings.go +++ /dev/null @@ -1,48 +0,0 @@ -package cli - -import "fmt" - -// Sprintf formats a string (fmt.Sprintf wrapper). -func Sprintf(format string, args ...any) string { - return fmt.Sprintf(format, args...) -} - -// Sprint formats using default formats (fmt.Sprint wrapper). -func Sprint(args ...any) string { - return fmt.Sprint(args...) -} - -// Styled returns text with a style applied. -func Styled(style *AnsiStyle, text string) string { - return style.Render(text) -} - -// Styledf returns formatted text with a style applied. -func Styledf(style *AnsiStyle, format string, args ...any) string { - return style.Render(fmt.Sprintf(format, args...)) -} - -// SuccessStr returns success-styled string. -func SuccessStr(msg string) string { - return SuccessStyle.Render(Glyph(":check:") + " " + msg) -} - -// ErrorStr returns error-styled string. -func ErrorStr(msg string) string { - return ErrorStyle.Render(Glyph(":cross:") + " " + msg) -} - -// WarnStr returns warning-styled string. -func WarnStr(msg string) string { - return WarningStyle.Render(Glyph(":warn:") + " " + msg) -} - -// InfoStr returns info-styled string. -func InfoStr(msg string) string { - return InfoStyle.Render(Glyph(":info:") + " " + msg) -} - -// DimStr returns dim-styled string. -func DimStr(msg string) string { - return DimStyle.Render(msg) -} diff --git a/pkg/cli/styles.go b/pkg/cli/styles.go deleted file mode 100644 index ab44cef..0000000 --- a/pkg/cli/styles.go +++ /dev/null @@ -1,211 +0,0 @@ -// Package cli provides semantic CLI output with zero external dependencies. -package cli - -import ( - "fmt" - "strings" - "time" -) - -// Tailwind colour palette (hex strings) -const ( - ColourBlue50 = "#eff6ff" - ColourBlue100 = "#dbeafe" - ColourBlue200 = "#bfdbfe" - ColourBlue300 = "#93c5fd" - ColourBlue400 = "#60a5fa" - ColourBlue500 = "#3b82f6" - ColourBlue600 = "#2563eb" - ColourBlue700 = "#1d4ed8" - ColourGreen400 = "#4ade80" - ColourGreen500 = "#22c55e" - ColourGreen600 = "#16a34a" - ColourRed400 = "#f87171" - ColourRed500 = "#ef4444" - ColourRed600 = "#dc2626" - ColourAmber400 = "#fbbf24" - ColourAmber500 = "#f59e0b" - ColourAmber600 = "#d97706" - ColourOrange500 = "#f97316" - ColourYellow500 = "#eab308" - ColourEmerald500 = "#10b981" - ColourPurple500 = "#a855f7" - ColourViolet400 = "#a78bfa" - ColourViolet500 = "#8b5cf6" - ColourIndigo500 = "#6366f1" - ColourCyan500 = "#06b6d4" - ColourGray50 = "#f9fafb" - ColourGray100 = "#f3f4f6" - ColourGray200 = "#e5e7eb" - ColourGray300 = "#d1d5db" - ColourGray400 = "#9ca3af" - ColourGray500 = "#6b7280" - ColourGray600 = "#4b5563" - ColourGray700 = "#374151" - ColourGray800 = "#1f2937" - ColourGray900 = "#111827" -) - -// Core styles -var ( - SuccessStyle = NewStyle().Bold().Foreground(ColourGreen500) - ErrorStyle = NewStyle().Bold().Foreground(ColourRed500) - WarningStyle = NewStyle().Bold().Foreground(ColourAmber500) - InfoStyle = NewStyle().Foreground(ColourBlue400) - SecurityStyle = NewStyle().Bold().Foreground(ColourPurple500) - DimStyle = NewStyle().Dim().Foreground(ColourGray500) - MutedStyle = NewStyle().Foreground(ColourGray600) - BoldStyle = NewStyle().Bold() - KeyStyle = NewStyle().Foreground(ColourGray400) - ValueStyle = NewStyle().Foreground(ColourGray200) - AccentStyle = NewStyle().Foreground(ColourCyan500) - LinkStyle = NewStyle().Foreground(ColourBlue500).Underline() - HeaderStyle = NewStyle().Bold().Foreground(ColourGray200) - TitleStyle = NewStyle().Bold().Foreground(ColourBlue500) - CodeStyle = NewStyle().Foreground(ColourGray300) - NumberStyle = NewStyle().Foreground(ColourBlue300) - RepoStyle = NewStyle().Bold().Foreground(ColourBlue500) -) - -// Truncate shortens a string to max length with ellipsis. -func Truncate(s string, max int) string { - if len(s) <= max { - return s - } - if max <= 3 { - return s[:max] - } - return s[:max-3] + "..." -} - -// Pad right-pads a string to width. -func Pad(s string, width int) string { - if len(s) >= width { - return s - } - return s + strings.Repeat(" ", width-len(s)) -} - -// FormatAge formats a time as human-readable age (e.g., "2h ago", "3d ago"). -func FormatAge(t time.Time) string { - d := time.Since(t) - switch { - case d < time.Minute: - return "just now" - case d < time.Hour: - return fmt.Sprintf("%dm ago", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%dh ago", int(d.Hours())) - case d < 7*24*time.Hour: - return fmt.Sprintf("%dd ago", int(d.Hours()/24)) - case d < 30*24*time.Hour: - return fmt.Sprintf("%dw ago", int(d.Hours()/(24*7))) - default: - return fmt.Sprintf("%dmo ago", int(d.Hours()/(24*30))) - } -} - -// Table renders tabular data with aligned columns. -// HLCRF is for layout; Table is for tabular data - they serve different purposes. -type Table struct { - Headers []string - Rows [][]string - Style TableStyle -} - -// TableStyle configures the appearance of table output. -type TableStyle struct { - HeaderStyle *AnsiStyle - CellStyle *AnsiStyle - Separator string -} - -// DefaultTableStyle returns sensible defaults. -func DefaultTableStyle() TableStyle { - return TableStyle{ - HeaderStyle: HeaderStyle, - CellStyle: nil, - Separator: " ", - } -} - -// NewTable creates a table with headers. -func NewTable(headers ...string) *Table { - return &Table{ - Headers: headers, - Style: DefaultTableStyle(), - } -} - -// AddRow adds a row to the table. -func (t *Table) AddRow(cells ...string) *Table { - t.Rows = append(t.Rows, cells) - return t -} - -// String renders the table. -func (t *Table) String() string { - if len(t.Headers) == 0 && len(t.Rows) == 0 { - return "" - } - - // Calculate column widths - cols := len(t.Headers) - if cols == 0 && len(t.Rows) > 0 { - cols = len(t.Rows[0]) - } - widths := make([]int, cols) - - for i, h := range t.Headers { - if len(h) > widths[i] { - widths[i] = len(h) - } - } - for _, row := range t.Rows { - for i, cell := range row { - if i < cols && len(cell) > widths[i] { - widths[i] = len(cell) - } - } - } - - var sb strings.Builder - sep := t.Style.Separator - - // Headers - if len(t.Headers) > 0 { - for i, h := range t.Headers { - if i > 0 { - sb.WriteString(sep) - } - styled := Pad(h, widths[i]) - if t.Style.HeaderStyle != nil { - styled = t.Style.HeaderStyle.Render(styled) - } - sb.WriteString(styled) - } - sb.WriteString("\n") - } - - // Rows - for _, row := range t.Rows { - for i, cell := range row { - if i > 0 { - sb.WriteString(sep) - } - styled := Pad(cell, widths[i]) - if t.Style.CellStyle != nil { - styled = t.Style.CellStyle.Render(styled) - } - sb.WriteString(styled) - } - sb.WriteString("\n") - } - - return sb.String() -} - -// Render prints the table to stdout. -func (t *Table) Render() { - fmt.Print(t.String()) -} diff --git a/pkg/cli/utils.go b/pkg/cli/utils.go deleted file mode 100644 index ed012d2..0000000 --- a/pkg/cli/utils.go +++ /dev/null @@ -1,505 +0,0 @@ -package cli - -import ( - "bufio" - "context" - "fmt" - "os" - "os/exec" - "strings" - "time" - - "forge.lthn.ai/core/go/pkg/i18n" - "forge.lthn.ai/core/go/pkg/log" -) - -// GhAuthenticated checks if the GitHub CLI is authenticated. -// Returns true if 'gh auth status' indicates a logged-in user. -func GhAuthenticated() bool { - cmd := exec.Command("gh", "auth", "status") - output, _ := cmd.CombinedOutput() - authenticated := strings.Contains(string(output), "Logged in") - - if authenticated { - LogSecurity("GitHub CLI authenticated", "user", log.Username()) - } else { - LogSecurity("GitHub CLI not authenticated", "user", log.Username()) - } - - return authenticated -} - -// ConfirmOption configures Confirm behaviour. -type ConfirmOption func(*confirmConfig) - -type confirmConfig struct { - defaultYes bool - required bool - timeout time.Duration -} - -// DefaultYes sets the default response to "yes" (pressing Enter confirms). -func DefaultYes() ConfirmOption { - return func(c *confirmConfig) { - c.defaultYes = true - } -} - -// Required prevents empty responses; user must explicitly type y/n. -func Required() ConfirmOption { - return func(c *confirmConfig) { - c.required = true - } -} - -// Timeout sets a timeout after which the default response is auto-selected. -// If no default is set (not Required and not DefaultYes), defaults to "no". -// -// Confirm("Continue?", Timeout(30*time.Second)) // Auto-no after 30s -// Confirm("Continue?", DefaultYes(), Timeout(10*time.Second)) // Auto-yes after 10s -func Timeout(d time.Duration) ConfirmOption { - return func(c *confirmConfig) { - c.timeout = d - } -} - -// Confirm prompts the user for yes/no confirmation. -// Returns true if the user enters "y" or "yes" (case-insensitive). -// -// Basic usage: -// -// if Confirm("Delete file?") { ... } -// -// With options: -// -// if Confirm("Save changes?", DefaultYes()) { ... } -// if Confirm("Dangerous!", Required()) { ... } -// if Confirm("Auto-continue?", Timeout(30*time.Second)) { ... } -func Confirm(prompt string, opts ...ConfirmOption) bool { - cfg := &confirmConfig{} - for _, opt := range opts { - opt(cfg) - } - - // Build the prompt suffix - var suffix string - if cfg.required { - suffix = "[y/n] " - } else if cfg.defaultYes { - suffix = "[Y/n] " - } else { - suffix = "[y/N] " - } - - // Add timeout indicator if set - if cfg.timeout > 0 { - suffix = fmt.Sprintf("%s(auto in %s) ", suffix, cfg.timeout.Round(time.Second)) - } - - reader := bufio.NewReader(os.Stdin) - - for { - fmt.Printf("%s %s", prompt, suffix) - - var response string - - if cfg.timeout > 0 { - // Use timeout-based reading - resultChan := make(chan string, 1) - go func() { - line, _ := reader.ReadString('\n') - resultChan <- line - }() - - select { - case response = <-resultChan: - response = strings.ToLower(strings.TrimSpace(response)) - case <-time.After(cfg.timeout): - fmt.Println() // New line after timeout - return cfg.defaultYes - } - } else { - response, _ = reader.ReadString('\n') - response = strings.ToLower(strings.TrimSpace(response)) - } - - // Handle empty response - if response == "" { - if cfg.required { - continue // Ask again - } - return cfg.defaultYes - } - - // Check for yes/no responses - if response == "y" || response == "yes" { - return true - } - if response == "n" || response == "no" { - return false - } - - // Invalid response - if cfg.required { - fmt.Println("Please enter 'y' or 'n'") - continue - } - - // Non-required: treat invalid as default - return cfg.defaultYes - } -} - -// ConfirmAction prompts for confirmation of an action using grammar composition. -// -// if ConfirmAction("delete", "config.yaml") { ... } -// if ConfirmAction("save", "changes", DefaultYes()) { ... } -func ConfirmAction(verb, subject string, opts ...ConfirmOption) bool { - question := i18n.Title(verb) + " " + subject + "?" - return Confirm(question, opts...) -} - -// ConfirmDangerousAction prompts for double confirmation of a dangerous action. -// Shows initial question, then a "Really verb subject?" confirmation. -// -// if ConfirmDangerousAction("delete", "config.yaml") { ... } -func ConfirmDangerousAction(verb, subject string) bool { - question := i18n.Title(verb) + " " + subject + "?" - if !Confirm(question, Required()) { - return false - } - - confirm := "Really " + verb + " " + subject + "?" - return Confirm(confirm, Required()) -} - -// QuestionOption configures Question behaviour. -type QuestionOption func(*questionConfig) - -type questionConfig struct { - defaultValue string - required bool - validator func(string) error -} - -// WithDefault sets the default value shown in brackets. -func WithDefault(value string) QuestionOption { - return func(c *questionConfig) { - c.defaultValue = value - } -} - -// WithValidator adds a validation function for the response. -func WithValidator(fn func(string) error) QuestionOption { - return func(c *questionConfig) { - c.validator = fn - } -} - -// RequiredInput prevents empty responses. -func RequiredInput() QuestionOption { - return func(c *questionConfig) { - c.required = true - } -} - -// Question prompts the user for text input. -// -// name := Question("Enter your name:") -// name := Question("Enter your name:", WithDefault("Anonymous")) -// name := Question("Enter your name:", RequiredInput()) -func Question(prompt string, opts ...QuestionOption) string { - cfg := &questionConfig{} - for _, opt := range opts { - opt(cfg) - } - - reader := bufio.NewReader(os.Stdin) - - for { - // Build prompt with default - if cfg.defaultValue != "" { - fmt.Printf("%s [%s] ", prompt, cfg.defaultValue) - } else { - fmt.Printf("%s ", prompt) - } - - response, _ := reader.ReadString('\n') - response = strings.TrimSpace(response) - - // Handle empty response - if response == "" { - if cfg.required { - fmt.Println("Response required") - continue - } - response = cfg.defaultValue - } - - // Validate if validator provided - if cfg.validator != nil { - if err := cfg.validator(response); err != nil { - fmt.Printf("Invalid: %v\n", err) - continue - } - } - - return response - } -} - -// QuestionAction prompts for text input using grammar composition. -// -// name := QuestionAction("rename", "old.txt") -func QuestionAction(verb, subject string, opts ...QuestionOption) string { - question := i18n.Title(verb) + " " + subject + "?" - return Question(question, opts...) -} - -// ChooseOption configures Choose behaviour. -type ChooseOption[T any] func(*chooseConfig[T]) - -type chooseConfig[T any] struct { - displayFn func(T) string - defaultN int // 0-based index of default selection - filter bool // Enable fuzzy filtering - multi bool // Allow multiple selection -} - -// WithDisplay sets a custom display function for items. -func WithDisplay[T any](fn func(T) string) ChooseOption[T] { - return func(c *chooseConfig[T]) { - c.displayFn = fn - } -} - -// WithDefaultIndex sets the default selection index (0-based). -func WithDefaultIndex[T any](idx int) ChooseOption[T] { - return func(c *chooseConfig[T]) { - c.defaultN = idx - } -} - -// Filter enables type-to-filter functionality. -// Users can type to narrow down the list of options. -// Note: This is a hint for interactive UIs; the basic CLI Choose -// implementation uses numbered selection which doesn't support filtering. -func Filter[T any]() ChooseOption[T] { - return func(c *chooseConfig[T]) { - c.filter = true - } -} - -// Multi allows multiple selections. -// Use ChooseMulti instead of Choose when this option is needed. -func Multi[T any]() ChooseOption[T] { - return func(c *chooseConfig[T]) { - c.multi = true - } -} - -// Display sets a custom display function for items. -// Alias for WithDisplay for shorter syntax. -// -// Choose("Select:", items, Display(func(f File) string { return f.Name })) -func Display[T any](fn func(T) string) ChooseOption[T] { - return WithDisplay[T](fn) -} - -// Choose prompts the user to select from a list of items. -// Returns the selected item. Uses simple numbered selection for terminal compatibility. -// -// choice := Choose("Select a file:", files) -// choice := Choose("Select a file:", files, WithDisplay(func(f File) string { return f.Name })) -func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T { - var zero T - if len(items) == 0 { - return zero - } - - cfg := &chooseConfig[T]{ - displayFn: func(item T) string { return fmt.Sprint(item) }, - } - for _, opt := range opts { - opt(cfg) - } - - // Display options - fmt.Println(prompt) - for i, item := range items { - marker := " " - if i == cfg.defaultN { - marker = "*" - } - fmt.Printf(" %s%d. %s\n", marker, i+1, cfg.displayFn(item)) - } - - reader := bufio.NewReader(os.Stdin) - - for { - fmt.Printf("Enter number [1-%d]: ", len(items)) - response, _ := reader.ReadString('\n') - response = strings.TrimSpace(response) - - // Empty response uses default - if response == "" { - return items[cfg.defaultN] - } - - // Parse number - var n int - if _, err := fmt.Sscanf(response, "%d", &n); err == nil { - if n >= 1 && n <= len(items) { - return items[n-1] - } - } - - fmt.Printf("Please enter a number between 1 and %d\n", len(items)) - } -} - -// ChooseAction prompts for selection using grammar composition. -// -// file := ChooseAction("select", "file", files) -func ChooseAction[T any](verb, subject string, items []T, opts ...ChooseOption[T]) T { - question := i18n.Title(verb) + " " + subject + ":" - return Choose(question, items, opts...) -} - -// ChooseMulti prompts the user to select multiple items from a list. -// Returns the selected items. Uses space-separated numbers or ranges. -// -// choices := ChooseMulti("Select files:", files) -// choices := ChooseMulti("Select files:", files, WithDisplay(func(f File) string { return f.Name })) -// -// Input format: -// - "1 3 5" - select items 1, 3, and 5 -// - "1-3" - select items 1, 2, and 3 -// - "1 3-5" - select items 1, 3, 4, and 5 -// - "" (empty) - select none -func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T { - if len(items) == 0 { - return nil - } - - cfg := &chooseConfig[T]{ - displayFn: func(item T) string { return fmt.Sprint(item) }, - } - for _, opt := range opts { - opt(cfg) - } - - // Display options - fmt.Println(prompt) - for i, item := range items { - fmt.Printf(" %d. %s\n", i+1, cfg.displayFn(item)) - } - - reader := bufio.NewReader(os.Stdin) - - for { - fmt.Printf("Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ") - response, _ := reader.ReadString('\n') - response = strings.TrimSpace(response) - - // Empty response returns no selections - if response == "" { - return nil - } - - // Parse the selection - selected, err := parseMultiSelection(response, len(items)) - if err != nil { - fmt.Printf("Invalid selection: %v\n", err) - continue - } - - // Build result - result := make([]T, 0, len(selected)) - for _, idx := range selected { - result = append(result, items[idx]) - } - return result - } -} - -// parseMultiSelection parses a multi-selection string like "1 3 5" or "1-3 5". -// Returns 0-based indices. -func parseMultiSelection(input string, maxItems int) ([]int, error) { - selected := make(map[int]bool) - parts := strings.Fields(input) - - for _, part := range parts { - // Check for range (e.g., "1-3") - if strings.Contains(part, "-") { - rangeParts := strings.Split(part, "-") - if len(rangeParts) != 2 { - return nil, fmt.Errorf("invalid range: %s", part) - } - var start, end int - if _, err := fmt.Sscanf(rangeParts[0], "%d", &start); err != nil { - return nil, fmt.Errorf("invalid range start: %s", rangeParts[0]) - } - if _, err := fmt.Sscanf(rangeParts[1], "%d", &end); err != nil { - return nil, fmt.Errorf("invalid range end: %s", rangeParts[1]) - } - if start < 1 || start > maxItems || end < 1 || end > maxItems || start > end { - return nil, fmt.Errorf("range out of bounds: %s", part) - } - for i := start; i <= end; i++ { - selected[i-1] = true // Convert to 0-based - } - } else { - // Single number - var n int - if _, err := fmt.Sscanf(part, "%d", &n); err != nil { - return nil, fmt.Errorf("invalid number: %s", part) - } - if n < 1 || n > maxItems { - return nil, fmt.Errorf("number out of range: %d", n) - } - selected[n-1] = true // Convert to 0-based - } - } - - // Convert map to sorted slice - result := make([]int, 0, len(selected)) - for i := 0; i < maxItems; i++ { - if selected[i] { - result = append(result, i) - } - } - return result, nil -} - -// ChooseMultiAction prompts for multiple selections using grammar composition. -// -// files := ChooseMultiAction("select", "files", files) -func ChooseMultiAction[T any](verb, subject string, items []T, opts ...ChooseOption[T]) []T { - question := i18n.Title(verb) + " " + subject + ":" - return ChooseMulti(question, items, opts...) -} - -// GitClone clones a GitHub repository to the specified path. -// Prefers 'gh repo clone' if authenticated, falls back to SSH. -func GitClone(ctx context.Context, org, repo, path string) error { - if GhAuthenticated() { - httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo) - cmd := exec.CommandContext(ctx, "gh", "repo", "clone", httpsURL, path) - output, err := cmd.CombinedOutput() - if err == nil { - return nil - } - errStr := strings.TrimSpace(string(output)) - if strings.Contains(errStr, "already exists") { - return fmt.Errorf("%s", errStr) - } - } - // Fall back to SSH clone - cmd := exec.CommandContext(ctx, "git", "clone", fmt.Sprintf("git@github.com:%s/%s.git", org, repo), path) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("%s", strings.TrimSpace(string(output))) - } - return nil -}