From 57ad74d4e2d9179b4c6b8fdfefb0ee6d325bccd7 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 22 Feb 2026 23:08:03 +0000 Subject: [PATCH] refactor: delete pkg/cli, migrate imports to core/cli pkg/cli now lives in forge.lthn.ai/core/cli as its own module. All cmd/gocmd imports updated. qa docblock check stubbed pending go-devops circular dependency resolution. Co-Authored-By: Claude Opus 4.6 --- cmd/gocmd/cmd_format.go | 2 +- cmd/gocmd/cmd_fuzz.go | 2 +- cmd/gocmd/cmd_go.go | 2 +- cmd/gocmd/cmd_gotest.go | 2 +- cmd/gocmd/cmd_qa.go | 23 +- cmd/gocmd/cmd_tools.go | 2 +- cmd/gocmd/coverage_test.go | 2 +- go.mod | 10 +- go.sum | 23 +- pkg/cli/ansi.go | 163 ------------ pkg/cli/ansi_test.go | 97 ------- pkg/cli/app.go | 160 ------------ 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 | 35 --- 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/list.go | 144 ---------- pkg/cli/list_test.go | 52 ---- pkg/cli/log.go | 115 -------- pkg/cli/output.go | 195 -------------- pkg/cli/output_test.go | 101 -------- pkg/cli/progressbar.go | 106 -------- pkg/cli/progressbar_test.go | 60 ----- pkg/cli/prompt.go | 75 ------ pkg/cli/render.go | 87 ------- pkg/cli/runtime.go | 216 --------------- pkg/cli/spinner.go | 107 -------- pkg/cli/spinner_test.go | 41 --- pkg/cli/strings.go | 48 ---- pkg/cli/stubs.go | 146 ----------- pkg/cli/stubs_test.go | 35 --- pkg/cli/styles.go | 211 --------------- pkg/cli/textinput.go | 183 ------------- pkg/cli/textinput_test.go | 59 ----- pkg/cli/tui.go | 85 ------ pkg/cli/tui_test.go | 55 ---- pkg/cli/utils.go | 505 ------------------------------------ pkg/cli/viewport.go | 176 ------------- pkg/cli/viewport_test.go | 61 ----- 49 files changed, 35 insertions(+), 5202 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/list.go delete mode 100644 pkg/cli/list_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/progressbar.go delete mode 100644 pkg/cli/progressbar_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/spinner.go delete mode 100644 pkg/cli/spinner_test.go delete mode 100644 pkg/cli/strings.go delete mode 100644 pkg/cli/stubs.go delete mode 100644 pkg/cli/stubs_test.go delete mode 100644 pkg/cli/styles.go delete mode 100644 pkg/cli/textinput.go delete mode 100644 pkg/cli/textinput_test.go delete mode 100644 pkg/cli/tui.go delete mode 100644 pkg/cli/tui_test.go delete mode 100644 pkg/cli/utils.go delete mode 100644 pkg/cli/viewport.go delete mode 100644 pkg/cli/viewport_test.go diff --git a/cmd/gocmd/cmd_format.go b/cmd/gocmd/cmd_format.go index ff5dc2b..111a579 100644 --- a/cmd/gocmd/cmd_format.go +++ b/cmd/gocmd/cmd_format.go @@ -7,7 +7,7 @@ import ( "path/filepath" "strings" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" ) diff --git a/cmd/gocmd/cmd_fuzz.go b/cmd/gocmd/cmd_fuzz.go index ce909ca..c0276f9 100644 --- a/cmd/gocmd/cmd_fuzz.go +++ b/cmd/gocmd/cmd_fuzz.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" ) diff --git a/cmd/gocmd/cmd_go.go b/cmd/gocmd/cmd_go.go index 2c2fbec..d74b727 100644 --- a/cmd/gocmd/cmd_go.go +++ b/cmd/gocmd/cmd_go.go @@ -4,7 +4,7 @@ package gocmd import ( - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" ) diff --git a/cmd/gocmd/cmd_gotest.go b/cmd/gocmd/cmd_gotest.go index 52971a1..0f8b433 100644 --- a/cmd/gocmd/cmd_gotest.go +++ b/cmd/gocmd/cmd_gotest.go @@ -12,7 +12,7 @@ import ( "strconv" "strings" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" ) diff --git a/cmd/gocmd/cmd_qa.go b/cmd/gocmd/cmd_qa.go index 62d4439..73a8773 100644 --- a/cmd/gocmd/cmd_qa.go +++ b/cmd/gocmd/cmd_qa.go @@ -10,8 +10,7 @@ import ( "strings" "time" - "forge.lthn.ai/core/cli/cmd/qa" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" ) @@ -614,24 +613,8 @@ func runInternalCheck(check QACheck) (string, error) { return "", runGoFuzz(duration, "", "", qaVerbose) case "docblock": - result, err := qa.CheckDocblockCoverage([]string{"./..."}) - if err != nil { - return "", err - } - result.Threshold = qaDocblockThreshold - result.Passed = result.Coverage >= qaDocblockThreshold - - if !result.Passed { - var output strings.Builder - output.WriteString(fmt.Sprintf("Docblock coverage: %.1f%% (threshold: %.1f%%)\n", - result.Coverage, qaDocblockThreshold)) - for _, m := range result.Missing { - output.WriteString(fmt.Sprintf("%s:%d\n", m.File, m.Line)) - } - return output.String(), cli.Err("docblock coverage %.1f%% below threshold %.1f%%", - result.Coverage, qaDocblockThreshold) - } - return fmt.Sprintf("Docblock coverage: %.1f%%", result.Coverage), nil + // TODO: migrate to go-devops/cmd/qa.CheckDocblockCoverage once circular dep resolved + return "", cli.Err("docblock check not yet available (pending go-devops migration)") default: return "", cli.Err("unknown internal check: %s", check.Name) diff --git a/cmd/gocmd/cmd_tools.go b/cmd/gocmd/cmd_tools.go index 0283062..2602124 100644 --- a/cmd/gocmd/cmd_tools.go +++ b/cmd/gocmd/cmd_tools.go @@ -6,7 +6,7 @@ import ( "os/exec" "path/filepath" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" ) diff --git a/cmd/gocmd/coverage_test.go b/cmd/gocmd/coverage_test.go index 53cc346..f48c6a9 100644 --- a/cmd/gocmd/coverage_test.go +++ b/cmd/gocmd/coverage_test.go @@ -4,7 +4,7 @@ import ( "os" "testing" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "github.com/stretchr/testify/assert" ) diff --git a/go.mod b/go.mod index cb87c6c..7af1bd5 100644 --- a/go.mod +++ b/go.mod @@ -2,19 +2,16 @@ module forge.lthn.ai/core/go go 1.26.0 -require forge.lthn.ai/core/go-crypt v0.0.1 - require ( forge.lthn.ai/Snider/Borg v0.2.1 + forge.lthn.ai/core/cli v0.0.1 + forge.lthn.ai/core/go-crypt v0.0.1 github.com/aws/aws-sdk-go-v2 v1.41.1 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 - github.com/charmbracelet/bubbletea v1.3.10 github.com/gorilla/websocket v1.5.3 - github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.48.0 - golang.org/x/term v0.40.0 golang.org/x/text v0.34.0 google.golang.org/grpc v1.79.1 google.golang.org/protobuf v1.36.11 @@ -34,6 +31,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect github.com/charmbracelet/x/ansi v0.11.4 // indirect @@ -66,6 +64,7 @@ require ( github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect @@ -75,6 +74,7 @@ require ( golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect gonum.org/v1/gonum v0.17.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/go.sum b/go.sum index 9d26d68..b93ec83 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ -forge.lthn.ai/Snider/Borg v0.2.1 h1:tsbbLQukDm4fyTkBDi98cwzoWkcCVXBOl9lhoxNDWJ4= -forge.lthn.ai/core/go-crypt v0.0.1 h1:i8CFFbpda528HL9uUcGvvRHsXSbX/j8FezGRKHBg2dA= +forge.lthn.ai/Snider/Borg v0.2.1 h1:Uf/YtUJLL8jlxTCjvP4J+5GHe3LLeALGtbh7zj8d8Qc= +forge.lthn.ai/Snider/Borg v0.2.1/go.mod h1:MVfolb7F6/A2LOIijcbBhWImu5db5NSMcSjvShMoMCA= +forge.lthn.ai/core/cli v0.0.1 h1:nqpc4Tv8a4H/ERei+/71DVQxkCFU8HPFJP4120qPXgk= +forge.lthn.ai/core/cli v0.0.1/go.mod h1:xa3Nqw3sUtYYJ1k+1jYul18tgs6sBevCUsGsHJI1hHA= +forge.lthn.ai/core/go-crypt v0.0.1 h1:fmFc2SJ/VOXDRjkcYoLWfL7lI4HfPJeVS/Na6zHHcvw= +forge.lthn.ai/core/go-crypt v0.0.1/go.mod h1:/j/rUN2ZMV7x1B5BYxH3QdwkgZg0HNBw5XuyFZeyxBY= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= @@ -31,13 +35,21 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI= +github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4= github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= +github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE= +github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g= +github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -79,11 +91,13 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -125,10 +139,15 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= 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 c02cc16..0000000 --- a/pkg/cli/app.go +++ /dev/null @@ -1,160 +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. -// Pass command services via WithCommands to register CLI commands -// through the Core framework lifecycle. -// -// cli.Main( -// cli.WithCommands("config", config.AddConfigCommands), -// cli.WithCommands("doctor", doctor.AddDoctorCommands), -// ) -// -// Exits with code 1 on error or panic. -func Main(commands ...framework.Option) { - // 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)) - } - }() - - // Core services load first, then command services - services := []framework.Option{ - framework.WithName("i18n", NewI18nService(I18nOptions{})), - framework.WithName("log", NewLogService(log.Options{ - Level: log.LevelInfo, - })), - framework.WithName("workspace", workspace.New), - } - services = append(services, commands...) - - // Initialise CLI runtime with services - if err := Init(Options{ - AppName: AppName, - Version: SemVer(), - Services: services, - }); 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 f481974..0000000 --- a/pkg/cli/commands.go +++ /dev/null @@ -1,35 +0,0 @@ -// Package cli provides the CLI runtime and utilities. -package cli - -import ( - "context" - - "forge.lthn.ai/core/go/pkg/framework" - "github.com/spf13/cobra" -) - -// WithCommands creates a framework Option that registers a command group. -// The register function receives the root command during service startup, -// allowing commands to participate in the Core lifecycle. -// -// cli.Main( -// cli.WithCommands("config", config.AddConfigCommands), -// cli.WithCommands("doctor", doctor.AddDoctorCommands), -// ) -func WithCommands(name string, register func(root *Command)) framework.Option { - return framework.WithName("cmd."+name, func(c *framework.Core) (any, error) { - return &commandService{core: c, register: register}, nil - }) -} - -type commandService struct { - core *framework.Core - register func(root *Command) -} - -func (s *commandService) OnStartup(_ context.Context) error { - if root, ok := s.core.App.(*cobra.Command); ok { - s.register(root) - } - return nil -} 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/list.go b/pkg/cli/list.go deleted file mode 100644 index 3c9d7da..0000000 --- a/pkg/cli/list.go +++ /dev/null @@ -1,144 +0,0 @@ -package cli - -import ( - "fmt" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "golang.org/x/term" -) - -// listModel is the internal bubbletea model for interactive list selection. -type listModel struct { - items []string - cursor int - title string - selected bool - quitted bool -} - -func newListModel(items []string, title string) *listModel { - return &listModel{ - items: items, - title: title, - } -} - -func (m *listModel) moveDown() { - m.cursor++ - if m.cursor >= len(m.items) { - m.cursor = 0 - } -} - -func (m *listModel) moveUp() { - m.cursor-- - if m.cursor < 0 { - m.cursor = len(m.items) - 1 - } -} - -func (m *listModel) Init() tea.Cmd { - return nil -} - -func (m *listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.Type { - case tea.KeyUp, tea.KeyShiftTab: - m.moveUp() - case tea.KeyDown, tea.KeyTab: - m.moveDown() - case tea.KeyEnter: - m.selected = true - return m, tea.Quit - case tea.KeyEscape, tea.KeyCtrlC: - m.quitted = true - return m, tea.Quit - case tea.KeyRunes: - switch string(msg.Runes) { - case "j": - m.moveDown() - case "k": - m.moveUp() - } - } - } - return m, nil -} - -func (m *listModel) View() string { - var sb strings.Builder - - if m.title != "" { - sb.WriteString(BoldStyle.Render(m.title) + "\n\n") - } - - for i, item := range m.items { - cursor := " " - style := DimStyle - if i == m.cursor { - cursor = AccentStyle.Render(Glyph(":pointer:")) + " " - style = BoldStyle - } - sb.WriteString(fmt.Sprintf("%s%s\n", cursor, style.Render(item))) - } - - sb.WriteString("\n" + DimStyle.Render("↑/↓ navigate • enter select • esc cancel")) - - return sb.String() -} - -// ListOption configures InteractiveList behaviour. -type ListOption func(*listConfig) - -type listConfig struct { - height int -} - -// WithListHeight sets the visible height of the list (number of items shown). -func WithListHeight(n int) ListOption { - return func(c *listConfig) { - c.height = n - } -} - -// InteractiveList presents an interactive scrollable list and returns the -// selected item's index and value. Returns -1 and empty string if cancelled. -// -// Falls back to numbered Select() when stdin is not a terminal (e.g. piped input). -// -// idx, value := cli.InteractiveList("Pick a repo:", repos) -func InteractiveList(title string, items []string, opts ...ListOption) (int, string) { - if len(items) == 0 { - return -1, "" - } - - // Fall back to simple Select if not a terminal - if !term.IsTerminal(0) { - result, err := Select(title, items) - if err != nil { - return -1, "" - } - for i, item := range items { - if item == result { - return i, result - } - } - return -1, "" - } - - m := newListModel(items, title) - p := tea.NewProgram(m) - finalModel, err := p.Run() - if err != nil { - return -1, "" - } - - final := finalModel.(*listModel) - if final.quitted || !final.selected { - return -1, "" - } - return final.cursor, final.items[final.cursor] -} diff --git a/pkg/cli/list_test.go b/pkg/cli/list_test.go deleted file mode 100644 index 202e323..0000000 --- a/pkg/cli/list_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package cli - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestListModel_Good_Create(t *testing.T) { - items := []string{"alpha", "beta", "gamma"} - m := newListModel(items, "Pick one:") - assert.Equal(t, 3, len(m.items)) - assert.Equal(t, 0, m.cursor) - assert.Equal(t, "Pick one:", m.title) -} - -func TestListModel_Good_MoveDown(t *testing.T) { - m := newListModel([]string{"a", "b", "c"}, "") - m.moveDown() - assert.Equal(t, 1, m.cursor) - m.moveDown() - assert.Equal(t, 2, m.cursor) -} - -func TestListModel_Good_MoveUp(t *testing.T) { - m := newListModel([]string{"a", "b", "c"}, "") - m.moveDown() - m.moveDown() - m.moveUp() - assert.Equal(t, 1, m.cursor) -} - -func TestListModel_Good_WrapAround(t *testing.T) { - m := newListModel([]string{"a", "b", "c"}, "") - m.moveUp() // Should wrap to bottom - assert.Equal(t, 2, m.cursor) -} - -func TestListModel_Good_View(t *testing.T) { - m := newListModel([]string{"alpha", "beta"}, "Choose:") - view := m.View() - assert.Contains(t, view, "Choose:") - assert.Contains(t, view, "alpha") - assert.Contains(t, view, "beta") -} - -func TestListModel_Good_Selected(t *testing.T) { - m := newListModel([]string{"a", "b", "c"}, "") - m.moveDown() - m.selected = true - assert.Equal(t, "b", m.items[m.cursor]) -} 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/progressbar.go b/pkg/cli/progressbar.go deleted file mode 100644 index 76b488f..0000000 --- a/pkg/cli/progressbar.go +++ /dev/null @@ -1,106 +0,0 @@ -package cli - -import ( - "fmt" - "strings" - "sync" -) - -// ProgressHandle controls a progress bar. -type ProgressHandle struct { - mu sync.Mutex - current int - total int - message string - width int -} - -// NewProgressBar creates a new progress bar with the given total. -func NewProgressBar(total int) *ProgressHandle { - return &ProgressHandle{ - total: total, - width: 30, - } -} - -// Current returns the current progress value. -func (p *ProgressHandle) Current() int { - p.mu.Lock() - defer p.mu.Unlock() - return p.current -} - -// Total returns the total value. -func (p *ProgressHandle) Total() int { - return p.total -} - -// Increment advances the progress by 1. -func (p *ProgressHandle) Increment() { - p.mu.Lock() - defer p.mu.Unlock() - if p.current < p.total { - p.current++ - } - p.render() -} - -// Set sets the progress to a specific value. -func (p *ProgressHandle) Set(n int) { - p.mu.Lock() - defer p.mu.Unlock() - if n > p.total { - n = p.total - } - if n < 0 { - n = 0 - } - p.current = n - p.render() -} - -// SetMessage sets the message displayed alongside the bar. -func (p *ProgressHandle) SetMessage(msg string) { - p.mu.Lock() - defer p.mu.Unlock() - p.message = msg - p.render() -} - -// Done completes the progress bar and moves to a new line. -func (p *ProgressHandle) Done() { - p.mu.Lock() - defer p.mu.Unlock() - p.current = p.total - p.render() - fmt.Println() -} - -// String returns the rendered progress bar without ANSI cursor control. -func (p *ProgressHandle) String() string { - pct := 0 - if p.total > 0 { - pct = (p.current * 100) / p.total - } - - filled := 0 - if p.total > 0 { - filled = (p.width * p.current) / p.total - } - if filled > p.width { - filled = p.width - } - empty := p.width - filled - - bar := "[" + strings.Repeat("\u2588", filled) + strings.Repeat("\u2591", empty) + "]" - - if p.message != "" { - return fmt.Sprintf("%s %3d%% %s", bar, pct, p.message) - } - return fmt.Sprintf("%s %3d%%", bar, pct) -} - -// render outputs the progress bar, overwriting the current line. -func (p *ProgressHandle) render() { - fmt.Printf("\033[2K\r%s", p.String()) -} diff --git a/pkg/cli/progressbar_test.go b/pkg/cli/progressbar_test.go deleted file mode 100644 index afa621f..0000000 --- a/pkg/cli/progressbar_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package cli - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestProgressBar_Good_Create(t *testing.T) { - pb := NewProgressBar(100) - require.NotNil(t, pb) - assert.Equal(t, 0, pb.Current()) - assert.Equal(t, 100, pb.Total()) -} - -func TestProgressBar_Good_Increment(t *testing.T) { - pb := NewProgressBar(10) - pb.Increment() - assert.Equal(t, 1, pb.Current()) - pb.Increment() - assert.Equal(t, 2, pb.Current()) -} - -func TestProgressBar_Good_SetMessage(t *testing.T) { - pb := NewProgressBar(10) - pb.SetMessage("Processing file.go") - assert.Equal(t, "Processing file.go", pb.message) -} - -func TestProgressBar_Good_Set(t *testing.T) { - pb := NewProgressBar(100) - pb.Set(50) - assert.Equal(t, 50, pb.Current()) -} - -func TestProgressBar_Good_Done(t *testing.T) { - pb := NewProgressBar(5) - for i := 0; i < 5; i++ { - pb.Increment() - } - pb.Done() - // After Done, Current == Total - assert.Equal(t, 5, pb.Current()) -} - -func TestProgressBar_Bad_ExceedsTotal(t *testing.T) { - pb := NewProgressBar(2) - pb.Increment() - pb.Increment() - pb.Increment() // Should clamp to total - assert.Equal(t, 2, pb.Current()) -} - -func TestProgressBar_Good_Render(t *testing.T) { - pb := NewProgressBar(10) - pb.Set(5) - rendered := pb.String() - assert.Contains(t, rendered, "50%") -} 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 c0dd383..0000000 --- a/pkg/cli/runtime.go +++ /dev/null @@ -1,216 +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, - } - - // 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/spinner.go b/pkg/cli/spinner.go deleted file mode 100644 index 7d08f08..0000000 --- a/pkg/cli/spinner.go +++ /dev/null @@ -1,107 +0,0 @@ -package cli - -import ( - "fmt" - "sync" - "time" -) - -// SpinnerHandle controls a running spinner. -type SpinnerHandle struct { - mu sync.Mutex - message string - done bool - ticker *time.Ticker - stopCh chan struct{} -} - -// NewSpinner starts an async spinner with the given message. -// Call Stop(), Done(), or Fail() to stop it. -func NewSpinner(message string) *SpinnerHandle { - s := &SpinnerHandle{ - message: message, - ticker: time.NewTicker(100 * time.Millisecond), - stopCh: make(chan struct{}), - } - - frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} - if !ColorEnabled() { - frames = []string{"|", "/", "-", "\\"} - } - - go func() { - i := 0 - for { - select { - case <-s.stopCh: - return - case <-s.ticker.C: - s.mu.Lock() - if !s.done { - fmt.Printf("\033[2K\r%s %s", DimStyle.Render(frames[i%len(frames)]), s.message) - } - s.mu.Unlock() - i++ - } - } - }() - - return s -} - -// Message returns the current spinner message. -func (s *SpinnerHandle) Message() string { - s.mu.Lock() - defer s.mu.Unlock() - return s.message -} - -// Update changes the spinner message. -func (s *SpinnerHandle) Update(message string) { - s.mu.Lock() - defer s.mu.Unlock() - s.message = message -} - -// Stop stops the spinner silently (clears the line). -func (s *SpinnerHandle) Stop() { - s.mu.Lock() - defer s.mu.Unlock() - if s.done { - return - } - s.done = true - s.ticker.Stop() - close(s.stopCh) - fmt.Print("\033[2K\r") -} - -// Done stops the spinner with a success message. -func (s *SpinnerHandle) Done(message string) { - s.mu.Lock() - alreadyDone := s.done - s.done = true - s.mu.Unlock() - - if alreadyDone { - return - } - s.ticker.Stop() - close(s.stopCh) - fmt.Printf("\033[2K\r%s\n", SuccessStyle.Render(Glyph(":check:")+" "+message)) -} - -// Fail stops the spinner with an error message. -func (s *SpinnerHandle) Fail(message string) { - s.mu.Lock() - alreadyDone := s.done - s.done = true - s.mu.Unlock() - - if alreadyDone { - return - } - s.ticker.Stop() - close(s.stopCh) - fmt.Printf("\033[2K\r%s\n", ErrorStyle.Render(Glyph(":cross:")+" "+message)) -} diff --git a/pkg/cli/spinner_test.go b/pkg/cli/spinner_test.go deleted file mode 100644 index 0ce294b..0000000 --- a/pkg/cli/spinner_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package cli - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSpinner_Good_CreateAndStop(t *testing.T) { - s := NewSpinner("Loading...") - require.NotNil(t, s) - assert.Equal(t, "Loading...", s.Message()) - s.Stop() -} - -func TestSpinner_Good_UpdateMessage(t *testing.T) { - s := NewSpinner("Step 1") - s.Update("Step 2") - assert.Equal(t, "Step 2", s.Message()) - s.Stop() -} - -func TestSpinner_Good_Done(t *testing.T) { - s := NewSpinner("Building") - s.Done("Build complete") - // After Done, spinner is stopped — calling Stop again is safe - s.Stop() -} - -func TestSpinner_Good_Fail(t *testing.T) { - s := NewSpinner("Checking") - s.Fail("Check failed") - s.Stop() -} - -func TestSpinner_Good_DoubleStop(t *testing.T) { - s := NewSpinner("Loading") - s.Stop() - s.Stop() // Should not panic -} 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/stubs.go b/pkg/cli/stubs.go deleted file mode 100644 index 4708d7f..0000000 --- a/pkg/cli/stubs.go +++ /dev/null @@ -1,146 +0,0 @@ -package cli - -// ────────────────────────────────────────────────────────────────────────────── -// Form (stubbed — simple fallback, will use charmbracelet/huh later) -// ────────────────────────────────────────────────────────────────────────────── - -// FieldType defines the type of a form field. -type FieldType string - -const ( - FieldText FieldType = "text" - FieldPassword FieldType = "password" - FieldConfirm FieldType = "confirm" - FieldSelect FieldType = "select" -) - -// FormField describes a single field in a form. -type FormField struct { - Label string - Key string - Type FieldType - Default string - Placeholder string - Options []string // For FieldSelect - Required bool - Validator func(string) error -} - -// Form presents a multi-field form and returns the values keyed by FormField.Key. -// Currently falls back to sequential Question()/Confirm()/Select() calls. -// Will be replaced with charmbracelet/huh interactive form later. -// -// results, err := cli.Form([]cli.FormField{ -// {Label: "Name", Key: "name", Type: cli.FieldText, Required: true}, -// {Label: "Password", Key: "pass", Type: cli.FieldPassword}, -// {Label: "Accept terms?", Key: "terms", Type: cli.FieldConfirm}, -// }) -func Form(fields []FormField) (map[string]string, error) { - results := make(map[string]string, len(fields)) - - for _, f := range fields { - switch f.Type { - case FieldPassword: - val := Question(f.Label+":", WithDefault(f.Default)) - results[f.Key] = val - case FieldConfirm: - if Confirm(f.Label) { - results[f.Key] = "true" - } else { - results[f.Key] = "false" - } - case FieldSelect: - val, err := Select(f.Label, f.Options) - if err != nil { - return nil, err - } - results[f.Key] = val - default: // FieldText - var opts []QuestionOption - if f.Default != "" { - opts = append(opts, WithDefault(f.Default)) - } - if f.Required { - opts = append(opts, RequiredInput()) - } - if f.Validator != nil { - opts = append(opts, WithValidator(f.Validator)) - } - results[f.Key] = Question(f.Label+":", opts...) - } - } - - return results, nil -} - -// ────────────────────────────────────────────────────────────────────────────── -// FilePicker (stubbed — will use charmbracelet/filepicker later) -// ────────────────────────────────────────────────────────────────────────────── - -// FilePickerOption configures FilePicker behaviour. -type FilePickerOption func(*filePickerConfig) - -type filePickerConfig struct { - dir string - extensions []string -} - -// InDirectory sets the starting directory for the file picker. -func InDirectory(dir string) FilePickerOption { - return func(c *filePickerConfig) { - c.dir = dir - } -} - -// WithExtensions filters to specific file extensions (e.g. ".go", ".yaml"). -func WithExtensions(exts ...string) FilePickerOption { - return func(c *filePickerConfig) { - c.extensions = exts - } -} - -// FilePicker presents a file browser and returns the selected path. -// Currently falls back to a text prompt. Will be replaced with an -// interactive file browser later. -// -// path, err := cli.FilePicker(cli.InDirectory("."), cli.WithExtensions(".go")) -func FilePicker(opts ...FilePickerOption) (string, error) { - cfg := &filePickerConfig{dir: "."} - for _, opt := range opts { - opt(cfg) - } - - hint := "File path" - if cfg.dir != "." { - hint += " (from " + cfg.dir + ")" - } - return Question(hint + ":"), nil -} - -// ────────────────────────────────────────────────────────────────────────────── -// Tabs (stubbed — will use bubbletea model later) -// ────────────────────────────────────────────────────────────────────────────── - -// TabItem describes a tab with a title and content. -type TabItem struct { - Title string - Content string -} - -// Tabs displays tabbed content. Currently prints all tabs sequentially. -// Will be replaced with an interactive tab switcher later. -// -// cli.Tabs([]cli.TabItem{ -// {Title: "Overview", Content: summaryText}, -// {Title: "Details", Content: detailText}, -// }) -func Tabs(items []TabItem) error { - for i, tab := range items { - if i > 0 { - Blank() - } - Section(tab.Title) - Println("%s", tab.Content) - } - return nil -} diff --git a/pkg/cli/stubs_test.go b/pkg/cli/stubs_test.go deleted file mode 100644 index bd5769c..0000000 --- a/pkg/cli/stubs_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package cli - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestFormField_Good_Types(t *testing.T) { - fields := []FormField{ - {Label: "Name", Key: "name", Type: FieldText}, - {Label: "Password", Key: "pass", Type: FieldPassword}, - {Label: "Accept", Key: "ok", Type: FieldConfirm}, - } - assert.Equal(t, 3, len(fields)) - assert.Equal(t, FieldText, fields[0].Type) - assert.Equal(t, FieldPassword, fields[1].Type) - assert.Equal(t, FieldConfirm, fields[2].Type) -} - -func TestFieldType_Good_Constants(t *testing.T) { - assert.Equal(t, FieldType("text"), FieldText) - assert.Equal(t, FieldType("password"), FieldPassword) - assert.Equal(t, FieldType("confirm"), FieldConfirm) - assert.Equal(t, FieldType("select"), FieldSelect) -} - -func TestTabItem_Good_Structure(t *testing.T) { - tabs := []TabItem{ - {Title: "Overview", Content: "overview content"}, - {Title: "Details", Content: "detail content"}, - } - assert.Equal(t, 2, len(tabs)) - assert.Equal(t, "Overview", tabs[0].Title) -} 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/textinput.go b/pkg/cli/textinput.go deleted file mode 100644 index ad36adf..0000000 --- a/pkg/cli/textinput.go +++ /dev/null @@ -1,183 +0,0 @@ -package cli - -import ( - "fmt" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "golang.org/x/term" -) - -// textInputModel is the internal bubbletea model for text input. -type textInputModel struct { - title string - placeholder string - value string - masked bool - submitted bool - cancelled bool - cursorPos int - validator func(string) error - err error -} - -func newTextInputModel(title, placeholder string) *textInputModel { - return &textInputModel{ - title: title, - placeholder: placeholder, - } -} - -func (m *textInputModel) insertChar(ch rune) { - m.value = m.value[:m.cursorPos] + string(ch) + m.value[m.cursorPos:] - m.cursorPos++ -} - -func (m *textInputModel) backspace() { - if m.cursorPos > 0 { - m.value = m.value[:m.cursorPos-1] + m.value[m.cursorPos:] - m.cursorPos-- - } -} - -func (m *textInputModel) Init() tea.Cmd { - return nil -} - -func (m *textInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.Type { - case tea.KeyEnter: - if m.validator != nil { - if err := m.validator(m.value); err != nil { - m.err = err - return m, nil - } - } - if m.value == "" && m.placeholder != "" { - m.value = m.placeholder - } - m.submitted = true - return m, tea.Quit - case tea.KeyEscape, tea.KeyCtrlC: - m.cancelled = true - return m, tea.Quit - case tea.KeyBackspace: - m.backspace() - m.err = nil - case tea.KeyLeft: - if m.cursorPos > 0 { - m.cursorPos-- - } - case tea.KeyRight: - if m.cursorPos < len(m.value) { - m.cursorPos++ - } - case tea.KeyRunes: - for _, ch := range msg.Runes { - m.insertChar(ch) - } - m.err = nil - } - } - return m, nil -} - -func (m *textInputModel) View() string { - var sb strings.Builder - - sb.WriteString(BoldStyle.Render(m.title) + "\n\n") - - display := m.value - if m.masked { - display = strings.Repeat("*", len(m.value)) - } - - if display == "" && m.placeholder != "" { - sb.WriteString(DimStyle.Render(m.placeholder)) - } else { - sb.WriteString(display) - } - sb.WriteString(AccentStyle.Render("\u2588")) // Cursor block - - if m.err != nil { - sb.WriteString("\n" + ErrorStyle.Render(fmt.Sprintf(" %s", m.err))) - } - - sb.WriteString("\n\n" + DimStyle.Render("enter submit \u2022 esc cancel")) - - return sb.String() -} - -// TextInputOption configures TextInput behaviour. -type TextInputOption func(*textInputConfig) - -type textInputConfig struct { - placeholder string - masked bool - validator func(string) error -} - -// WithTextPlaceholder sets placeholder text shown when input is empty. -func WithTextPlaceholder(text string) TextInputOption { - return func(c *textInputConfig) { - c.placeholder = text - } -} - -// WithMask hides input characters (for passwords). -func WithMask() TextInputOption { - return func(c *textInputConfig) { - c.masked = true - } -} - -// WithInputValidator adds a validation function for the input. -func WithInputValidator(fn func(string) error) TextInputOption { - return func(c *textInputConfig) { - c.validator = fn - } -} - -// TextInput presents a styled text input prompt and returns the entered value. -// Returns empty string if cancelled. -// -// Falls back to Question() when stdin is not a terminal. -// -// name, err := cli.TextInput("Enter your name:", cli.WithTextPlaceholder("Anonymous")) -// pass, err := cli.TextInput("Password:", cli.WithMask()) -func TextInput(title string, opts ...TextInputOption) (string, error) { - cfg := &textInputConfig{} - for _, opt := range opts { - opt(cfg) - } - - // Fall back to simple Question if not a terminal - if !term.IsTerminal(0) { - var qopts []QuestionOption - if cfg.placeholder != "" { - qopts = append(qopts, WithDefault(cfg.placeholder)) - } - if cfg.validator != nil { - qopts = append(qopts, WithValidator(cfg.validator)) - } - return Question(title, qopts...), nil - } - - m := newTextInputModel(title, cfg.placeholder) - m.masked = cfg.masked - m.validator = cfg.validator - - p := tea.NewProgram(m) - finalModel, err := p.Run() - if err != nil { - return "", err - } - - final := finalModel.(*textInputModel) - if final.cancelled { - return "", nil - } - return final.value, nil -} diff --git a/pkg/cli/textinput_test.go b/pkg/cli/textinput_test.go deleted file mode 100644 index 9fec419..0000000 --- a/pkg/cli/textinput_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package cli - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestTextInputModel_Good_Create(t *testing.T) { - m := newTextInputModel("Enter name:", "") - assert.Equal(t, "Enter name:", m.title) - assert.Equal(t, "", m.value) -} - -func TestTextInputModel_Good_WithPlaceholder(t *testing.T) { - m := newTextInputModel("Name:", "John") - assert.Equal(t, "John", m.placeholder) -} - -func TestTextInputModel_Good_TypeCharacters(t *testing.T) { - m := newTextInputModel("Name:", "") - m.insertChar('H') - m.insertChar('i') - assert.Equal(t, "Hi", m.value) -} - -func TestTextInputModel_Good_Backspace(t *testing.T) { - m := newTextInputModel("Name:", "") - m.insertChar('A') - m.insertChar('B') - m.backspace() - assert.Equal(t, "A", m.value) -} - -func TestTextInputModel_Good_BackspaceEmpty(t *testing.T) { - m := newTextInputModel("Name:", "") - m.backspace() // Should not panic - assert.Equal(t, "", m.value) -} - -func TestTextInputModel_Good_Masked(t *testing.T) { - m := newTextInputModel("Password:", "") - m.masked = true - m.insertChar('s') - m.insertChar('e') - m.insertChar('c') - assert.Equal(t, "sec", m.value) // Internal value is real - view := m.View() - assert.NotContains(t, view, "sec") // Display is masked - assert.Contains(t, view, "***") -} - -func TestTextInputModel_Good_View(t *testing.T) { - m := newTextInputModel("Enter:", "") - m.insertChar('X') - view := m.View() - assert.Contains(t, view, "Enter:") - assert.Contains(t, view, "X") -} diff --git a/pkg/cli/tui.go b/pkg/cli/tui.go deleted file mode 100644 index 1b1af7f..0000000 --- a/pkg/cli/tui.go +++ /dev/null @@ -1,85 +0,0 @@ -package cli - -import ( - tea "github.com/charmbracelet/bubbletea" -) - -// Model is the interface for interactive TUI applications. -// It mirrors bubbletea's Model but uses our own types so domain -// packages never import bubbletea directly. -type Model interface { - // Init returns an initial command to run. - Init() Cmd - - // Update handles a message and returns the updated model and command. - Update(msg Msg) (Model, Cmd) - - // View returns the string representation of the UI. - View() string -} - -// Msg is a message passed to Update. Can be any type. -type Msg = tea.Msg - -// Cmd is a function that returns a message. Nil means no command. -type Cmd = tea.Cmd - -// Quit is a command that tells the TUI to exit. -var Quit = tea.Quit - -// KeyMsg represents a key press event. -type KeyMsg = tea.KeyMsg - -// KeyType represents the type of key pressed. -type KeyType = tea.KeyType - -// Key type constants. -const ( - KeyEnter KeyType = tea.KeyEnter - KeyEsc KeyType = tea.KeyEscape - KeyCtrlC KeyType = tea.KeyCtrlC - KeyUp KeyType = tea.KeyUp - KeyDown KeyType = tea.KeyDown - KeyLeft KeyType = tea.KeyLeft - KeyRight KeyType = tea.KeyRight - KeyTab KeyType = tea.KeyTab - KeyBackspace KeyType = tea.KeyBackspace - KeySpace KeyType = tea.KeySpace - KeyHome KeyType = tea.KeyHome - KeyEnd KeyType = tea.KeyEnd - KeyPgUp KeyType = tea.KeyPgUp - KeyPgDown KeyType = tea.KeyPgDown - KeyDelete KeyType = tea.KeyDelete - KeyShiftTab KeyType = tea.KeyShiftTab - KeyRunes KeyType = tea.KeyRunes -) - -// adapter wraps our Model interface into a bubbletea.Model. -type adapter struct { - inner Model -} - -func (a adapter) Init() tea.Cmd { - return a.inner.Init() -} - -func (a adapter) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - m, cmd := a.inner.Update(msg) - return adapter{inner: m}, cmd -} - -func (a adapter) View() string { - return a.inner.View() -} - -// RunTUI runs an interactive TUI application using the provided Model. -// This is the escape hatch for complex interactive UIs that need the -// full bubbletea event loop. For simple spinners, progress bars, and -// lists, use the dedicated helpers instead. -// -// err := cli.RunTUI(&myModel{items: items}) -func RunTUI(m Model) error { - p := tea.NewProgram(adapter{inner: m}) - _, err := p.Run() - return err -} diff --git a/pkg/cli/tui_test.go b/pkg/cli/tui_test.go deleted file mode 100644 index 4ff312f..0000000 --- a/pkg/cli/tui_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package cli - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -// testModel is a minimal Model that quits immediately. -type testModel struct { - initCalled bool - updateCalled bool - viewCalled bool -} - -func (m *testModel) Init() Cmd { - m.initCalled = true - return Quit -} - -func (m *testModel) Update(msg Msg) (Model, Cmd) { - m.updateCalled = true - return m, nil -} - -func (m *testModel) View() string { - m.viewCalled = true - return "test view" -} - -func TestModel_Good_InterfaceSatisfied(t *testing.T) { - var m Model = &testModel{} - assert.NotNil(t, m) -} - -func TestQuitCmd_Good_ReturnsQuitMsg(t *testing.T) { - cmd := Quit - assert.NotNil(t, cmd) -} - -func TestKeyMsg_Good_Type(t *testing.T) { - // Verify our re-exported KeyType constants match bubbletea's - assert.Equal(t, KeyEnter, KeyEnter) - assert.Equal(t, KeyEsc, KeyEsc) -} - -func TestKeyTypes_Good_Constants(t *testing.T) { - // Verify key type constants exist and are distinct - keys := []KeyType{KeyEnter, KeyEsc, KeyCtrlC, KeyUp, KeyDown, KeyTab, KeyBackspace} - seen := make(map[KeyType]bool) - for _, k := range keys { - assert.False(t, seen[k], "duplicate key type") - seen[k] = true - } -} 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 -} diff --git a/pkg/cli/viewport.go b/pkg/cli/viewport.go deleted file mode 100644 index 152aab7..0000000 --- a/pkg/cli/viewport.go +++ /dev/null @@ -1,176 +0,0 @@ -package cli - -import ( - "fmt" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "golang.org/x/term" -) - -// viewportModel is the internal bubbletea model for scrollable content. -type viewportModel struct { - title string - lines []string - offset int - height int - quitted bool -} - -func newViewportModel(content, title string, height int) *viewportModel { - lines := strings.Split(content, "\n") - return &viewportModel{ - title: title, - lines: lines, - height: height, - } -} - -func (m *viewportModel) scrollDown() { - maxOffset := len(m.lines) - m.height - if maxOffset < 0 { - maxOffset = 0 - } - if m.offset < maxOffset { - m.offset++ - } -} - -func (m *viewportModel) scrollUp() { - if m.offset > 0 { - m.offset-- - } -} - -func (m *viewportModel) Init() tea.Cmd { - return nil -} - -func (m *viewportModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.Type { - case tea.KeyUp: - m.scrollUp() - case tea.KeyDown: - m.scrollDown() - case tea.KeyPgUp: - for i := 0; i < m.height; i++ { - m.scrollUp() - } - case tea.KeyPgDown: - for i := 0; i < m.height; i++ { - m.scrollDown() - } - case tea.KeyHome: - m.offset = 0 - case tea.KeyEnd: - maxOffset := len(m.lines) - m.height - if maxOffset < 0 { - maxOffset = 0 - } - m.offset = maxOffset - case tea.KeyEscape, tea.KeyCtrlC: - m.quitted = true - return m, tea.Quit - case tea.KeyRunes: - switch string(msg.Runes) { - case "q": - m.quitted = true - return m, tea.Quit - case "j": - m.scrollDown() - case "k": - m.scrollUp() - case "g": - m.offset = 0 - case "G": - maxOffset := len(m.lines) - m.height - if maxOffset < 0 { - maxOffset = 0 - } - m.offset = maxOffset - } - } - } - return m, nil -} - -func (m *viewportModel) View() string { - var sb strings.Builder - - if m.title != "" { - sb.WriteString(BoldStyle.Render(m.title) + "\n") - sb.WriteString(DimStyle.Render(strings.Repeat("\u2500", len(m.title))) + "\n") - } - - // Visible window - end := m.offset + m.height - if end > len(m.lines) { - end = len(m.lines) - } - for _, line := range m.lines[m.offset:end] { - sb.WriteString(line + "\n") - } - - // Scroll indicator - total := len(m.lines) - if total > m.height { - pct := (m.offset * 100) / (total - m.height) - sb.WriteString(DimStyle.Render(fmt.Sprintf("\n%d%% (%d/%d lines)", pct, m.offset+m.height, total))) - } - - sb.WriteString("\n" + DimStyle.Render("\u2191/\u2193 scroll \u2022 PgUp/PgDn page \u2022 q quit")) - - return sb.String() -} - -// ViewportOption configures Viewport behaviour. -type ViewportOption func(*viewportConfig) - -type viewportConfig struct { - title string - height int -} - -// WithViewportTitle sets the title shown above the viewport. -func WithViewportTitle(title string) ViewportOption { - return func(c *viewportConfig) { - c.title = title - } -} - -// WithViewportHeight sets the visible height in lines. -func WithViewportHeight(n int) ViewportOption { - return func(c *viewportConfig) { - c.height = n - } -} - -// Viewport displays scrollable content in the terminal. -// Falls back to printing the full content when stdin is not a terminal. -// -// cli.Viewport(longContent, WithViewportTitle("Build Log"), WithViewportHeight(20)) -func Viewport(content string, opts ...ViewportOption) error { - cfg := &viewportConfig{ - height: 20, - } - for _, opt := range opts { - opt(cfg) - } - - // Fall back to plain output if not a terminal - if !term.IsTerminal(0) { - if cfg.title != "" { - fmt.Println(BoldStyle.Render(cfg.title)) - fmt.Println(DimStyle.Render(strings.Repeat("\u2500", len(cfg.title)))) - } - fmt.Println(content) - return nil - } - - m := newViewportModel(content, cfg.title, cfg.height) - p := tea.NewProgram(m) - _, err := p.Run() - return err -} diff --git a/pkg/cli/viewport_test.go b/pkg/cli/viewport_test.go deleted file mode 100644 index e33d7ad..0000000 --- a/pkg/cli/viewport_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package cli - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestViewportModel_Good_Create(t *testing.T) { - content := "line 1\nline 2\nline 3" - m := newViewportModel(content, "Title", 5) - assert.Equal(t, "Title", m.title) - assert.Equal(t, 3, len(m.lines)) - assert.Equal(t, 0, m.offset) -} - -func TestViewportModel_Good_ScrollDown(t *testing.T) { - lines := make([]string, 20) - for i := range lines { - lines[i] = strings.Repeat("x", 10) - } - m := newViewportModel(strings.Join(lines, "\n"), "", 5) - m.scrollDown() - assert.Equal(t, 1, m.offset) -} - -func TestViewportModel_Good_ScrollUp(t *testing.T) { - lines := make([]string, 20) - for i := range lines { - lines[i] = strings.Repeat("x", 10) - } - m := newViewportModel(strings.Join(lines, "\n"), "", 5) - m.scrollDown() - m.scrollDown() - m.scrollUp() - assert.Equal(t, 1, m.offset) -} - -func TestViewportModel_Good_NoScrollPastTop(t *testing.T) { - m := newViewportModel("a\nb\nc", "", 5) - m.scrollUp() // Already at top - assert.Equal(t, 0, m.offset) -} - -func TestViewportModel_Good_NoScrollPastBottom(t *testing.T) { - m := newViewportModel("a\nb\nc", "", 5) - for i := 0; i < 10; i++ { - m.scrollDown() - } - // Should clamp -- can't scroll past content - assert.GreaterOrEqual(t, m.offset, 0) -} - -func TestViewportModel_Good_View(t *testing.T) { - m := newViewportModel("line 1\nline 2", "My Title", 10) - view := m.View() - assert.Contains(t, view, "My Title") - assert.Contains(t, view, "line 1") - assert.Contains(t, view, "line 2") -}