From 6a8bd92189e67871e7ad51642f30f235e74c66a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 20:42:00 +0000 Subject: [PATCH] feat: add pkg/cli with TUI components (#14, #15) Move pkg/cli from core/go to core/cli. Includes Frame AppShell, Stream, TaskTracker, Tree, Rich Table. Update imports to v0.0.1 tagged deps and fix openpgp import path for go-crypt split. Co-Authored-By: Claude Opus 4.6 --- cmd/config/cmd.go | 2 +- cmd/config/cmd_get.go | 2 +- cmd/config/cmd_list.go | 2 +- cmd/config/cmd_path.go | 2 +- cmd/config/cmd_set.go | 2 +- cmd/doctor/cmd_doctor.go | 2 +- cmd/help/cmd.go | 2 +- cmd/module/cmd.go | 2 +- cmd/module/cmd_install.go | 2 +- cmd/module/cmd_list.go | 2 +- cmd/module/cmd_remove.go | 2 +- cmd/module/cmd_update.go | 2 +- cmd/pkgcmd/cmd_pkg.go | 2 +- cmd/plugin/cmd.go | 2 +- cmd/plugin/cmd_info.go | 2 +- cmd/plugin/cmd_install.go | 2 +- cmd/plugin/cmd_list.go | 2 +- cmd/plugin/cmd_remove.go | 2 +- cmd/plugin/cmd_update.go | 2 +- cmd/session/cmd_session.go | 2 +- go.mod | 25 +- go.sum | 44 +--- main.go | 2 +- pkg/cli/ansi.go | 163 ++++++++++++ pkg/cli/ansi_test.go | 97 +++++++ pkg/cli/app.go | 163 ++++++++++++ pkg/cli/app_test.go | 164 ++++++++++++ pkg/cli/check.go | 91 +++++++ pkg/cli/check_test.go | 49 ++++ pkg/cli/command.go | 210 +++++++++++++++ pkg/cli/commands.go | 50 ++++ pkg/cli/commands_test.go | 185 ++++++++++++++ pkg/cli/daemon.go | 446 ++++++++++++++++++++++++++++++++ pkg/cli/daemon_test.go | 234 +++++++++++++++++ pkg/cli/errors.go | 162 ++++++++++++ pkg/cli/frame.go | 358 ++++++++++++++++++++++++++ pkg/cli/frame_test.go | 207 +++++++++++++++ pkg/cli/glyph.go | 92 +++++++ pkg/cli/glyph_maps.go | 25 ++ pkg/cli/glyph_test.go | 23 ++ pkg/cli/i18n.go | 170 +++++++++++++ pkg/cli/layout.go | 148 +++++++++++ pkg/cli/layout_test.go | 25 ++ pkg/cli/log.go | 115 +++++++++ pkg/cli/output.go | 195 ++++++++++++++ pkg/cli/output_test.go | 101 ++++++++ pkg/cli/prompt.go | 75 ++++++ pkg/cli/render.go | 87 +++++++ pkg/cli/runtime.go | 219 ++++++++++++++++ pkg/cli/stream.go | 140 ++++++++++ pkg/cli/stream_test.go | 159 ++++++++++++ pkg/cli/strings.go | 48 ++++ pkg/cli/styles.go | 440 ++++++++++++++++++++++++++++++++ pkg/cli/styles_test.go | 206 +++++++++++++++ pkg/cli/tracker.go | 291 +++++++++++++++++++++ pkg/cli/tracker_test.go | 188 ++++++++++++++ pkg/cli/tree.go | 98 +++++++ pkg/cli/tree_test.go | 113 +++++++++ pkg/cli/utils.go | 505 +++++++++++++++++++++++++++++++++++++ 59 files changed, 6073 insertions(+), 80 deletions(-) create mode 100644 pkg/cli/ansi.go create mode 100644 pkg/cli/ansi_test.go create mode 100644 pkg/cli/app.go create mode 100644 pkg/cli/app_test.go create mode 100644 pkg/cli/check.go create mode 100644 pkg/cli/check_test.go create mode 100644 pkg/cli/command.go create mode 100644 pkg/cli/commands.go create mode 100644 pkg/cli/commands_test.go create mode 100644 pkg/cli/daemon.go create mode 100644 pkg/cli/daemon_test.go create mode 100644 pkg/cli/errors.go create mode 100644 pkg/cli/frame.go create mode 100644 pkg/cli/frame_test.go create mode 100644 pkg/cli/glyph.go create mode 100644 pkg/cli/glyph_maps.go create mode 100644 pkg/cli/glyph_test.go create mode 100644 pkg/cli/i18n.go create mode 100644 pkg/cli/layout.go create mode 100644 pkg/cli/layout_test.go create mode 100644 pkg/cli/log.go create mode 100644 pkg/cli/output.go create mode 100644 pkg/cli/output_test.go create mode 100644 pkg/cli/prompt.go create mode 100644 pkg/cli/render.go create mode 100644 pkg/cli/runtime.go create mode 100644 pkg/cli/stream.go create mode 100644 pkg/cli/stream_test.go create mode 100644 pkg/cli/strings.go create mode 100644 pkg/cli/styles.go create mode 100644 pkg/cli/styles_test.go create mode 100644 pkg/cli/tracker.go create mode 100644 pkg/cli/tracker_test.go create mode 100644 pkg/cli/tree.go create mode 100644 pkg/cli/tree_test.go create mode 100644 pkg/cli/utils.go diff --git a/cmd/config/cmd.go b/cmd/config/cmd.go index 73bc30b7..3fdd2f30 100644 --- a/cmd/config/cmd.go +++ b/cmd/config/cmd.go @@ -1,6 +1,6 @@ package config -import "forge.lthn.ai/core/go/pkg/cli" +import "forge.lthn.ai/core/cli/pkg/cli" // AddConfigCommands registers the 'config' command group and all subcommands. func AddConfigCommands(root *cli.Command) { diff --git a/cmd/config/cmd_get.go b/cmd/config/cmd_get.go index a4fa97f2..bbe67618 100644 --- a/cmd/config/cmd_get.go +++ b/cmd/config/cmd_get.go @@ -3,7 +3,7 @@ package config import ( "fmt" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/config" ) diff --git a/cmd/config/cmd_list.go b/cmd/config/cmd_list.go index ed697ff2..42b6148f 100644 --- a/cmd/config/cmd_list.go +++ b/cmd/config/cmd_list.go @@ -3,7 +3,7 @@ package config import ( "fmt" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "gopkg.in/yaml.v3" ) diff --git a/cmd/config/cmd_path.go b/cmd/config/cmd_path.go index 9cfdcd69..d9878127 100644 --- a/cmd/config/cmd_path.go +++ b/cmd/config/cmd_path.go @@ -3,7 +3,7 @@ package config import ( "fmt" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" ) func addPathCommand(parent *cli.Command) { diff --git a/cmd/config/cmd_set.go b/cmd/config/cmd_set.go index e39d0875..09e1fa91 100644 --- a/cmd/config/cmd_set.go +++ b/cmd/config/cmd_set.go @@ -1,7 +1,7 @@ package config import ( - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" ) func addSetCommand(parent *cli.Command) { diff --git a/cmd/doctor/cmd_doctor.go b/cmd/doctor/cmd_doctor.go index 606d9c9b..4e8a7040 100644 --- a/cmd/doctor/cmd_doctor.go +++ b/cmd/doctor/cmd_doctor.go @@ -4,7 +4,7 @@ package doctor import ( "fmt" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" "github.com/spf13/cobra" ) diff --git a/cmd/help/cmd.go b/cmd/help/cmd.go index 4d429cea..693a6bb0 100644 --- a/cmd/help/cmd.go +++ b/cmd/help/cmd.go @@ -3,7 +3,7 @@ package help import ( "fmt" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/help" ) diff --git a/cmd/module/cmd.go b/cmd/module/cmd.go index c6e7cccc..525e3fdb 100644 --- a/cmd/module/cmd.go +++ b/cmd/module/cmd.go @@ -11,7 +11,7 @@ import ( "os" "path/filepath" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/marketplace" "forge.lthn.ai/core/go/pkg/store" diff --git a/cmd/module/cmd_install.go b/cmd/module/cmd_install.go index b0fa9e37..0cade6b8 100644 --- a/cmd/module/cmd_install.go +++ b/cmd/module/cmd_install.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/marketplace" ) diff --git a/cmd/module/cmd_list.go b/cmd/module/cmd_list.go index 2b4fa5c3..79974896 100644 --- a/cmd/module/cmd_list.go +++ b/cmd/module/cmd_list.go @@ -3,7 +3,7 @@ package module import ( "fmt" - "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/module/cmd_remove.go b/cmd/module/cmd_remove.go index 07b20993..87e3aed8 100644 --- a/cmd/module/cmd_remove.go +++ b/cmd/module/cmd_remove.go @@ -1,7 +1,7 @@ package module 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/module/cmd_update.go b/cmd/module/cmd_update.go index 86cd242c..4b5fcf3c 100644 --- a/cmd/module/cmd_update.go +++ b/cmd/module/cmd_update.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "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/pkgcmd/cmd_pkg.go b/cmd/pkgcmd/cmd_pkg.go index 184bcff8..1eec8635 100644 --- a/cmd/pkgcmd/cmd_pkg.go +++ b/cmd/pkgcmd/cmd_pkg.go @@ -2,7 +2,7 @@ package pkgcmd import ( - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" "github.com/spf13/cobra" ) diff --git a/cmd/plugin/cmd.go b/cmd/plugin/cmd.go index 68558e82..4b38e045 100644 --- a/cmd/plugin/cmd.go +++ b/cmd/plugin/cmd.go @@ -9,7 +9,7 @@ package plugin 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/plugin/cmd_info.go b/cmd/plugin/cmd_info.go index 11b12696..be1b3b65 100644 --- a/cmd/plugin/cmd_info.go +++ b/cmd/plugin/cmd_info.go @@ -4,7 +4,7 @@ import ( "fmt" "path/filepath" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/plugin" diff --git a/cmd/plugin/cmd_install.go b/cmd/plugin/cmd_install.go index c1b9a07c..b86e8ec9 100644 --- a/cmd/plugin/cmd_install.go +++ b/cmd/plugin/cmd_install.go @@ -5,7 +5,7 @@ import ( "os" "path/filepath" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/plugin" diff --git a/cmd/plugin/cmd_list.go b/cmd/plugin/cmd_list.go index 9de08511..7693be12 100644 --- a/cmd/plugin/cmd_list.go +++ b/cmd/plugin/cmd_list.go @@ -3,7 +3,7 @@ package plugin import ( "fmt" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/plugin" diff --git a/cmd/plugin/cmd_remove.go b/cmd/plugin/cmd_remove.go index 4aa60bfd..e0522692 100644 --- a/cmd/plugin/cmd_remove.go +++ b/cmd/plugin/cmd_remove.go @@ -1,7 +1,7 @@ package plugin import ( - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/plugin" diff --git a/cmd/plugin/cmd_update.go b/cmd/plugin/cmd_update.go index 5e9e1aa6..0edcf997 100644 --- a/cmd/plugin/cmd_update.go +++ b/cmd/plugin/cmd_update.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/pkg/io" "forge.lthn.ai/core/go/pkg/plugin" diff --git a/cmd/session/cmd_session.go b/cmd/session/cmd_session.go index 08014119..73ce9fc3 100644 --- a/cmd/session/cmd_session.go +++ b/cmd/session/cmd_session.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/session" ) diff --git a/go.mod b/go.mod index 512dc8b8..c8163994 100644 --- a/go.mod +++ b/go.mod @@ -3,55 +3,42 @@ module forge.lthn.ai/core/cli go 1.26.0 require ( - forge.lthn.ai/core/go v0.0.0-20260221220640-2a90ae65b7c7 - forge.lthn.ai/core/go-crypt v0.0.0-20260221193816-fde12e1539b2 // indirect + forge.lthn.ai/core/go v0.0.1 + forge.lthn.ai/core/go-crypt v0.0.1 ) require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + golang.org/x/term v0.40.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/uniseg v0.4.7 // indirect 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/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect modernc.org/libc v1.67.7 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index b5374a38..8b15a7aa 100644 --- a/go.sum +++ b/go.sum @@ -1,25 +1,9 @@ -forge.lthn.ai/core/go v0.0.0-20260221191103-d091fa62023f h1:CcSh/FFY93K5m0vADHLxwxKn2pTIM8HzYX1eGa4WZf4= -forge.lthn.ai/core/go v0.0.0-20260221191103-d091fa62023f/go.mod h1:WCPJVEZm/6mTcJimHV0uX8ZhnKEF3dN0rQp13ByaSPg= -forge.lthn.ai/core/go v0.0.0-20260221220640-2a90ae65b7c7 h1:UhlJo4QeqKD0IM0wKjOh8H3OaDnvdl5m7psozRXJdO8= -forge.lthn.ai/core/go v0.0.0-20260221220640-2a90ae65b7c7/go.mod h1:WCPJVEZm/6mTcJimHV0uX8ZhnKEF3dN0rQp13ByaSPg= -forge.lthn.ai/core/go-crypt v0.0.0-20260221193816-fde12e1539b2 h1:2eXqQXF+1AyitPJox9Yjewb6w8fO0JHFw7gPqk8WqIM= -forge.lthn.ai/core/go-crypt v0.0.0-20260221193816-fde12e1539b2/go.mod h1:o4vkJgoT9u+r7DR42LIJHW6L5vMS3Au8gaaCA5Cved0= +forge.lthn.ai/core/go v0.0.1 h1:ubk4nmkA3treOUNgPS28wKd1jB6cUlEQUV7jDdGa3zM= +forge.lthn.ai/core/go v0.0.1/go.mod h1:59YsnuMaAGQUxIhX68oK2/HnhQJEPWL1iEZhDTrNCbY= +forge.lthn.ai/core/go-crypt v0.0.1 h1:i8CFFbpda528HL9uUcGvvRHsXSbX/j8FezGRKHBg2dA= +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/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -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.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 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= @@ -27,8 +11,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -49,20 +31,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -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= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= @@ -71,9 +41,6 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -94,8 +61,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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= @@ -106,7 +71,6 @@ golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/main.go b/main.go index 2bb5989d..1eafd5a6 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,7 @@ import ( "forge.lthn.ai/core/cli/cmd/pkgcmd" "forge.lthn.ai/core/cli/cmd/plugin" "forge.lthn.ai/core/cli/cmd/session" - "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/cli/pkg/cli" ) func main() { diff --git a/pkg/cli/ansi.go b/pkg/cli/ansi.go new file mode 100644 index 00000000..e4df66e3 --- /dev/null +++ b/pkg/cli/ansi.go @@ -0,0 +1,163 @@ +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 new file mode 100644 index 00000000..1ec7a3eb --- /dev/null +++ b/pkg/cli/ansi_test.go @@ -0,0 +1,97 @@ +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 new file mode 100644 index 00000000..b595ec8e --- /dev/null +++ b/pkg/cli/app.go @@ -0,0 +1,163 @@ +package cli + +import ( + "fmt" + "os" + "runtime/debug" + + "forge.lthn.ai/core/go-crypt/crypt/openpgp" + "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" +) + +// AppName is the default CLI application name. +// Override with WithAppName before calling Main. +var AppName = "core" + +// Build-time variables set via ldflags (SemVer 2.0.0): +// +// go build -ldflags="-X forge.lthn.ai/core/cli/pkg/cli.AppVersion=1.2.0 \ +// -X forge.lthn.ai/core/cli/pkg/cli.BuildCommit=df94c24 \ +// -X forge.lthn.ai/core/cli/pkg/cli.BuildDate=2026-02-06 \ +// -X forge.lthn.ai/core/cli/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 +} + +// WithAppName sets the application name used in help text and shell completion. +// Call before Main for variant binaries (e.g. "lem", "devops"). +// +// cli.WithAppName("lem") +// cli.Main() +func WithAppName(name string) { + AppName = name +} + +// Main initialises and runs the CLI application. +// This is the main entry point for the CLI. +// Exits with code 1 on error or panic. +func Main() { + // Recovery from panics + defer func() { + if r := recover(); r != nil { + log.Error("recovered from panic", "error", r, "stack", string(debug.Stack())) + Shutdown() + Fatal(fmt.Errorf("panic: %v", r)) + } + }() + + // Initialise CLI runtime with services + if err := Init(Options{ + AppName: AppName, + Version: SemVer(), + Services: []framework.Option{ + framework.WithName("i18n", NewI18nService(I18nOptions{})), + framework.WithName("log", NewLogService(log.Options{ + Level: log.LevelInfo, + })), + framework.WithName("crypt", openpgp.New), + framework.WithName("workspace", workspace.New), + }, + }); err != nil { + Error(err.Error()) + os.Exit(1) + } + defer Shutdown() + + // Add completion command to the CLI's root + RootCmd().AddCommand(newCompletionCmd()) + + if err := Execute(); err != nil { + code := 1 + var exitErr *ExitError + if As(err, &exitErr) { + code = exitErr.Code + } + Error(err.Error()) + os.Exit(code) + } +} + +// newCompletionCmd creates the shell completion command using the current AppName. +func newCompletionCmd() *cobra.Command { + return &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion script", + Long: fmt.Sprintf(`Generate shell completion script for the specified shell. + +To load completions: + +Bash: + $ source <(%s completion bash) + + # To load completions for each session, execute once: + # Linux: + $ %s completion bash > /etc/bash_completion.d/%s + # macOS: + $ %s completion bash > $(brew --prefix)/etc/bash_completion.d/%s + +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: + $ %s completion zsh > "${fpath[1]}/_%s" + + # You will need to start a new shell for this setup to take effect. + +Fish: + $ %s completion fish | source + + # To load completions for each session, execute once: + $ %s completion fish > ~/.config/fish/completions/%s.fish + +PowerShell: + PS> %s completion powershell | Out-String | Invoke-Expression + + # To load completions for every new session, run: + PS> %s completion powershell > %s.ps1 + # and source this file from your PowerShell profile. +`, AppName, AppName, AppName, AppName, AppName, + AppName, AppName, AppName, AppName, AppName, + AppName, AppName, AppName), + 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 new file mode 100644 index 00000000..c11d5fe6 --- /dev/null +++ b/pkg/cli/app_test.go @@ -0,0 +1,164 @@ +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 new file mode 100644 index 00000000..499cd890 --- /dev/null +++ b/pkg/cli/check.go @@ -0,0 +1,91 @@ +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 new file mode 100644 index 00000000..760853c3 --- /dev/null +++ b/pkg/cli/check_test.go @@ -0,0 +1,49 @@ +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 new file mode 100644 index 00000000..58ec8673 --- /dev/null +++ b/pkg/cli/command.go @@ -0,0 +1,210 @@ +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 +} + +// NewPassthrough creates a command that passes all arguments (including flags) +// to the given function. Used for commands that do their own flag parsing +// (e.g. incremental migration from flag.FlagSet to cobra). +// +// cmd := cli.NewPassthrough("train", "Train a model", func(args []string) { +// // args includes all flags: ["--model", "gemma-3-1b", "--epochs", "10"] +// fs := flag.NewFlagSet("train", flag.ExitOnError) +// // ... +// }) +func NewPassthrough(use, short string, fn func(args []string)) *Command { + cmd := NewRun(use, short, "", func(_ *Command, args []string) { + fn(args) + }) + cmd.DisableFlagParsing = true + 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 new file mode 100644 index 00000000..20ea2da8 --- /dev/null +++ b/pkg/cli/commands.go @@ -0,0 +1,50 @@ +// Package cli provides the CLI runtime and utilities. +package cli + +import ( + "sync" + + "github.com/spf13/cobra" +) + +// CommandRegistration is a function that adds commands to the root. +type CommandRegistration func(root *cobra.Command) + +var ( + registeredCommands []CommandRegistration + registeredCommandsMu sync.Mutex + commandsAttached bool +) + +// RegisterCommands registers a function that adds commands to the CLI. +// Call this in your package's init() to register commands. +// +// func init() { +// cli.RegisterCommands(AddCommands) +// } +// +// func AddCommands(root *cobra.Command) { +// root.AddCommand(myCmd) +// } +func RegisterCommands(fn CommandRegistration) { + registeredCommandsMu.Lock() + defer registeredCommandsMu.Unlock() + registeredCommands = append(registeredCommands, fn) + + // If commands already attached (CLI already running), attach immediately + if commandsAttached && instance != nil && instance.root != nil { + fn(instance.root) + } +} + +// attachRegisteredCommands calls all registered command functions. +// Called by Init() after creating the root command. +func attachRegisteredCommands(root *cobra.Command) { + registeredCommandsMu.Lock() + defer registeredCommandsMu.Unlock() + + for _, fn := range registeredCommands { + fn(root) + } + commandsAttached = true +} diff --git a/pkg/cli/commands_test.go b/pkg/cli/commands_test.go new file mode 100644 index 00000000..08654e4b --- /dev/null +++ b/pkg/cli/commands_test.go @@ -0,0 +1,185 @@ +package cli + +import ( + "sync" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// resetGlobals clears the CLI singleton and command registry for test isolation. +func resetGlobals(t *testing.T) { + t.Helper() + t.Cleanup(func() { + // Restore clean state after each test. + registeredCommandsMu.Lock() + registeredCommands = nil + commandsAttached = false + registeredCommandsMu.Unlock() + if instance != nil { + Shutdown() + } + instance = nil + once = sync.Once{} + }) + + registeredCommandsMu.Lock() + registeredCommands = nil + commandsAttached = false + registeredCommandsMu.Unlock() + if instance != nil { + Shutdown() + } + instance = nil + once = sync.Once{} +} + +// TestRegisterCommands_Good tests the happy path for command registration. +func TestRegisterCommands_Good(t *testing.T) { + t.Run("registers on startup", func(t *testing.T) { + resetGlobals(t) + + RegisterCommands(func(root *cobra.Command) { + root.AddCommand(&cobra.Command{Use: "hello", Short: "Say hello"}) + }) + + err := Init(Options{AppName: "test"}) + require.NoError(t, err) + + // The "hello" command should be on the root. + cmd, _, err := RootCmd().Find([]string{"hello"}) + require.NoError(t, err) + assert.Equal(t, "hello", cmd.Use) + }) + + t.Run("multiple groups compose", func(t *testing.T) { + resetGlobals(t) + + RegisterCommands(func(root *cobra.Command) { + root.AddCommand(&cobra.Command{Use: "alpha", Short: "Alpha"}) + }) + RegisterCommands(func(root *cobra.Command) { + root.AddCommand(&cobra.Command{Use: "beta", Short: "Beta"}) + }) + + err := Init(Options{AppName: "test"}) + require.NoError(t, err) + + for _, name := range []string{"alpha", "beta"} { + cmd, _, err := RootCmd().Find([]string{name}) + require.NoError(t, err) + assert.Equal(t, name, cmd.Use) + } + }) + + t.Run("group with subcommands", func(t *testing.T) { + resetGlobals(t) + + RegisterCommands(func(root *cobra.Command) { + grp := &cobra.Command{Use: "ml", Short: "ML commands"} + grp.AddCommand(&cobra.Command{Use: "train", Short: "Train a model"}) + grp.AddCommand(&cobra.Command{Use: "serve", Short: "Serve a model"}) + root.AddCommand(grp) + }) + + err := Init(Options{AppName: "test"}) + require.NoError(t, err) + + cmd, _, err := RootCmd().Find([]string{"ml", "train"}) + require.NoError(t, err) + assert.Equal(t, "train", cmd.Use) + + cmd, _, err = RootCmd().Find([]string{"ml", "serve"}) + require.NoError(t, err) + assert.Equal(t, "serve", cmd.Use) + }) + + t.Run("executes registered command", func(t *testing.T) { + resetGlobals(t) + + executed := false + RegisterCommands(func(root *cobra.Command) { + root.AddCommand(&cobra.Command{ + Use: "ping", + Short: "Ping", + RunE: func(_ *cobra.Command, _ []string) error { + executed = true + return nil + }, + }) + }) + + err := Init(Options{AppName: "test"}) + require.NoError(t, err) + + RootCmd().SetArgs([]string{"ping"}) + err = Execute() + require.NoError(t, err) + assert.True(t, executed, "registered command should have been executed") + }) +} + +// TestRegisterCommands_Bad tests expected error conditions. +func TestRegisterCommands_Bad(t *testing.T) { + t.Run("late registration attaches immediately", func(t *testing.T) { + resetGlobals(t) + + err := Init(Options{AppName: "test"}) + require.NoError(t, err) + + // Register after Init — should attach immediately. + RegisterCommands(func(root *cobra.Command) { + root.AddCommand(&cobra.Command{Use: "late", Short: "Late arrival"}) + }) + + cmd, _, err := RootCmd().Find([]string{"late"}) + require.NoError(t, err) + assert.Equal(t, "late", cmd.Use) + }) +} + +// TestWithAppName_Good tests the app name override. +func TestWithAppName_Good(t *testing.T) { + t.Run("overrides root command use", func(t *testing.T) { + resetGlobals(t) + + WithAppName("lem") + defer WithAppName("core") // restore + + err := Init(Options{AppName: AppName}) + require.NoError(t, err) + + assert.Equal(t, "lem", RootCmd().Use) + }) + + t.Run("default is core", func(t *testing.T) { + resetGlobals(t) + + err := Init(Options{AppName: AppName}) + require.NoError(t, err) + + assert.Equal(t, "core", RootCmd().Use) + }) +} + +// TestNewPassthrough_Good tests the passthrough command builder. +func TestNewPassthrough_Good(t *testing.T) { + t.Run("passes all args including flags", func(t *testing.T) { + var received []string + cmd := NewPassthrough("train", "Train", func(args []string) { + received = args + }) + + cmd.SetArgs([]string{"--model", "gemma", "--epochs", "10"}) + err := cmd.Execute() + require.NoError(t, err) + assert.Equal(t, []string{"--model", "gemma", "--epochs", "10"}, received) + }) + + t.Run("flag parsing is disabled", func(t *testing.T) { + cmd := NewPassthrough("run", "Run", func(_ []string) {}) + assert.True(t, cmd.DisableFlagParsing) + }) +} diff --git a/pkg/cli/daemon.go b/pkg/cli/daemon.go new file mode 100644 index 00000000..961bb268 --- /dev/null +++ b/pkg/cli/daemon.go @@ -0,0 +1,446 @@ +// 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 { + path string + mu sync.Mutex +} + +// NewPIDFile creates a PID file manager. +func NewPIDFile(path string) *PIDFile { + return &PIDFile{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 := io.Local.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 + _ = io.Local.Delete(p.path) + } + + // Ensure directory exists + if dir := filepath.Dir(p.path); dir != "." { + if err := io.Local.EnsureDir(dir); err != nil { + return fmt.Errorf("failed to create PID directory: %w", err) + } + } + + // Write current PID + pid := os.Getpid() + if err := io.Local.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 io.Local.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 { + // 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.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 new file mode 100644 index 00000000..fb12c459 --- /dev/null +++ b/pkg/cli/daemon_test.go @@ -0,0 +1,234 @@ +package cli + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "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) { + pidPath := filepath.Join(t.TempDir(), "test.pid") + + pid := NewPIDFile(pidPath) + + err := pid.Acquire() + require.NoError(t, err) + + err = pid.Release() + require.NoError(t, err) + }) + + t.Run("stale pid file", func(t *testing.T) { + pidPath := filepath.Join(t.TempDir(), "stale.pid") + + // Write a stale PID (non-existent process). + require.NoError(t, os.WriteFile(pidPath, []byte("999999999"), 0644)) + + pid := NewPIDFile(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) { + pidPath := filepath.Join(t.TempDir(), "subdir", "nested", "test.pid") + + pid := NewPIDFile(pidPath) + + err := pid.Acquire() + require.NoError(t, err) + + err = pid.Release() + require.NoError(t, err) + }) + + t.Run("path getter", func(t *testing.T) { + pid := NewPIDFile("/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) { + pidPath := filepath.Join(t.TempDir(), "test.pid") + + d := NewDaemon(DaemonOptions{ + 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) + }) + + 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 new file mode 100644 index 00000000..e74982c6 --- /dev/null +++ b/pkg/cli/errors.go @@ -0,0 +1,162 @@ +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/frame.go b/pkg/cli/frame.go new file mode 100644 index 00000000..a3aaff04 --- /dev/null +++ b/pkg/cli/frame.go @@ -0,0 +1,358 @@ +package cli + +import ( + "fmt" + "io" + "os" + "strings" + "sync" + "time" + + "golang.org/x/term" +) + +// Model is the interface for components that slot into Frame regions. +// View receives the allocated width and height and returns rendered text. +type Model interface { + View(width, height int) string +} + +// ModelFunc is a convenience adapter for using a function as a Model. +type ModelFunc func(width, height int) string + +// View implements Model. +func (f ModelFunc) View(width, height int) string { return f(width, height) } + +// Frame is a live compositional AppShell for TUI. +// Uses HLCRF variant strings for region layout — same as the static Layout system, +// but with live-updating Model components instead of static strings. +// +// frame := cli.NewFrame("HCF") +// frame.Header(cli.StatusLine("core dev", "18 repos", "main")) +// frame.Content(myTableModel) +// frame.Footer(cli.KeyHints("↑/↓ navigate", "enter select", "q quit")) +// frame.Run() +type Frame struct { + variant string + layout *Composite + models map[Region]Model + history []Model // content region stack for Navigate/Back + out io.Writer + done chan struct{} + mu sync.Mutex +} + +// NewFrame creates a new Frame with the given HLCRF variant string. +// +// frame := cli.NewFrame("HCF") // header, content, footer +// frame := cli.NewFrame("H[LC]F") // header, [left + content], footer +func NewFrame(variant string) *Frame { + return &Frame{ + variant: variant, + layout: Layout(variant), + models: make(map[Region]Model), + out: os.Stdout, + done: make(chan struct{}), + } +} + +// Header sets the Header region model. +func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f } + +// Left sets the Left sidebar region model. +func (f *Frame) Left(m Model) *Frame { f.setModel(RegionLeft, m); return f } + +// Content sets the Content region model. +func (f *Frame) Content(m Model) *Frame { f.setModel(RegionContent, m); return f } + +// Right sets the Right sidebar region model. +func (f *Frame) Right(m Model) *Frame { f.setModel(RegionRight, m); return f } + +// Footer sets the Footer region model. +func (f *Frame) Footer(m Model) *Frame { f.setModel(RegionFooter, m); return f } + +func (f *Frame) setModel(r Region, m Model) { + f.mu.Lock() + defer f.mu.Unlock() + f.models[r] = m +} + +// Navigate replaces the Content region with a new model, pushing the current one +// onto the history stack for Back(). +func (f *Frame) Navigate(m Model) { + f.mu.Lock() + defer f.mu.Unlock() + if current, ok := f.models[RegionContent]; ok { + f.history = append(f.history, current) + } + f.models[RegionContent] = m +} + +// Back pops the content history stack, restoring the previous Content model. +// Returns false if the history is empty. +func (f *Frame) Back() bool { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.history) == 0 { + return false + } + f.models[RegionContent] = f.history[len(f.history)-1] + f.history = f.history[:len(f.history)-1] + return true +} + +// Stop signals the Frame to exit its Run loop. +func (f *Frame) Stop() { + select { + case <-f.done: + default: + close(f.done) + } +} + +// Run renders the frame and blocks. In TTY mode, it live-refreshes at ~12fps. +// In non-TTY mode, it renders once and returns immediately. +func (f *Frame) Run() { + if !f.isTTY() { + fmt.Fprint(f.out, f.String()) + return + } + f.runLive() +} + +// RunFor runs the frame for a fixed duration, then stops. +// Useful for dashboards that refresh periodically. +func (f *Frame) RunFor(d time.Duration) { + go func() { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-timer.C: + f.Stop() + case <-f.done: + } + }() + f.Run() +} + +// String renders the frame as a static string (no ANSI, no live updates). +// This is the non-TTY fallback path. +func (f *Frame) String() string { + f.mu.Lock() + defer f.mu.Unlock() + + w, h := f.termSize() + var sb strings.Builder + + order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter} + for _, r := range order { + if _, exists := f.layout.regions[r]; !exists { + continue + } + m, ok := f.models[r] + if !ok { + continue + } + rw, rh := f.regionSize(r, w, h) + view := m.View(rw, rh) + if view != "" { + sb.WriteString(view) + if !strings.HasSuffix(view, "\n") { + sb.WriteByte('\n') + } + } + } + + return sb.String() +} + +func (f *Frame) isTTY() bool { + if file, ok := f.out.(*os.File); ok { + return term.IsTerminal(int(file.Fd())) + } + return false +} + +func (f *Frame) termSize() (int, int) { + if file, ok := f.out.(*os.File); ok { + w, h, err := term.GetSize(int(file.Fd())) + if err == nil { + return w, h + } + } + return 80, 24 // sensible default +} + +func (f *Frame) regionSize(r Region, totalW, totalH int) (int, int) { + // Simple allocation: Header/Footer get 1 line, sidebars get 1/4 width, + // Content gets the rest. + switch r { + case RegionHeader, RegionFooter: + return totalW, 1 + case RegionLeft, RegionRight: + return totalW / 4, totalH - 2 // minus header + footer + case RegionContent: + sideW := 0 + if _, ok := f.models[RegionLeft]; ok { + sideW += totalW / 4 + } + if _, ok := f.models[RegionRight]; ok { + sideW += totalW / 4 + } + return totalW - sideW, totalH - 2 + } + return totalW, totalH +} + +func (f *Frame) runLive() { + // Enter alt-screen. + fmt.Fprint(f.out, "\033[?1049h") + // Hide cursor. + fmt.Fprint(f.out, "\033[?25l") + + defer func() { + // Show cursor. + fmt.Fprint(f.out, "\033[?25h") + // Leave alt-screen. + fmt.Fprint(f.out, "\033[?1049l") + }() + + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + + for { + f.renderFrame() + + select { + case <-f.done: + return + case <-ticker.C: + } + } +} + +func (f *Frame) renderFrame() { + f.mu.Lock() + defer f.mu.Unlock() + + w, h := f.termSize() + + // Move to top-left. + fmt.Fprint(f.out, "\033[H") + // Clear screen. + fmt.Fprint(f.out, "\033[2J") + + order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter} + for _, r := range order { + if _, exists := f.layout.regions[r]; !exists { + continue + } + m, ok := f.models[r] + if !ok { + continue + } + rw, rh := f.regionSize(r, w, h) + view := m.View(rw, rh) + if view != "" { + fmt.Fprint(f.out, view) + if !strings.HasSuffix(view, "\n") { + fmt.Fprintln(f.out) + } + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Built-in Region Components +// ───────────────────────────────────────────────────────────────────────────── + +// statusLineModel renders a "title key:value key:value" bar. +type statusLineModel struct { + title string + pairs []string +} + +// StatusLine creates a header/footer bar with a title and key:value pairs. +// +// frame.Header(cli.StatusLine("core dev", "18 repos", "main")) +func StatusLine(title string, pairs ...string) Model { + return &statusLineModel{title: title, pairs: pairs} +} + +func (s *statusLineModel) View(width, _ int) string { + parts := []string{BoldStyle.Render(s.title)} + for _, p := range s.pairs { + parts = append(parts, DimStyle.Render(p)) + } + line := strings.Join(parts, " ") + if width > 0 { + line = Truncate(line, width) + } + return line +} + +// keyHintsModel renders keyboard shortcut hints. +type keyHintsModel struct { + hints []string +} + +// KeyHints creates a footer showing keyboard shortcuts. +// +// frame.Footer(cli.KeyHints("↑/↓ navigate", "enter select", "q quit")) +func KeyHints(hints ...string) Model { + return &keyHintsModel{hints: hints} +} + +func (k *keyHintsModel) View(width, _ int) string { + parts := make([]string, len(k.hints)) + for i, h := range k.hints { + parts[i] = DimStyle.Render(h) + } + line := strings.Join(parts, " ") + if width > 0 { + line = Truncate(line, width) + } + return line +} + +// breadcrumbModel renders a navigation path. +type breadcrumbModel struct { + parts []string +} + +// Breadcrumb creates a navigation breadcrumb bar. +// +// frame.Header(cli.Breadcrumb("core", "dev", "health")) +func Breadcrumb(parts ...string) Model { + return &breadcrumbModel{parts: parts} +} + +func (b *breadcrumbModel) View(width, _ int) string { + styled := make([]string, len(b.parts)) + for i, p := range b.parts { + if i == len(b.parts)-1 { + styled[i] = BoldStyle.Render(p) + } else { + styled[i] = DimStyle.Render(p) + } + } + line := strings.Join(styled, DimStyle.Render(" > ")) + if width > 0 { + line = Truncate(line, width) + } + return line +} + +// staticModel wraps a plain string as a Model. +type staticModel struct { + text string +} + +// StaticModel wraps a static string as a Model, for use in Frame regions. +func StaticModel(text string) Model { + return &staticModel{text: text} +} + +func (s *staticModel) View(_, _ int) string { + return s.text +} diff --git a/pkg/cli/frame_test.go b/pkg/cli/frame_test.go new file mode 100644 index 00000000..c6dfd73b --- /dev/null +++ b/pkg/cli/frame_test.go @@ -0,0 +1,207 @@ +package cli + +import ( + "bytes" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFrame_Good(t *testing.T) { + t.Run("static render HCF", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + f := NewFrame("HCF") + f.out = &bytes.Buffer{} + f.Header(StaticModel("header")) + f.Content(StaticModel("content")) + f.Footer(StaticModel("footer")) + + out := f.String() + assert.Contains(t, out, "header") + assert.Contains(t, out, "content") + assert.Contains(t, out, "footer") + }) + + t.Run("region order preserved", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + f := NewFrame("HCF") + f.out = &bytes.Buffer{} + f.Header(StaticModel("AAA")) + f.Content(StaticModel("BBB")) + f.Footer(StaticModel("CCC")) + + out := f.String() + posA := indexOf(out, "AAA") + posB := indexOf(out, "BBB") + posC := indexOf(out, "CCC") + assert.Less(t, posA, posB, "header before content") + assert.Less(t, posB, posC, "content before footer") + }) + + t.Run("navigate and back", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + f := NewFrame("HCF") + f.out = &bytes.Buffer{} + f.Header(StaticModel("nav")) + f.Content(StaticModel("page-1")) + f.Footer(StaticModel("hints")) + + assert.Contains(t, f.String(), "page-1") + + // Navigate to page 2 + f.Navigate(StaticModel("page-2")) + assert.Contains(t, f.String(), "page-2") + assert.NotContains(t, f.String(), "page-1") + + // Navigate to page 3 + f.Navigate(StaticModel("page-3")) + assert.Contains(t, f.String(), "page-3") + + // Back to page 2 + ok := f.Back() + require.True(t, ok) + assert.Contains(t, f.String(), "page-2") + + // Back to page 1 + ok = f.Back() + require.True(t, ok) + assert.Contains(t, f.String(), "page-1") + + // No more history + ok = f.Back() + assert.False(t, ok) + }) + + t.Run("empty regions skipped", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + f := NewFrame("HCF") + f.out = &bytes.Buffer{} + f.Content(StaticModel("only content")) + + out := f.String() + assert.Equal(t, "only content\n", out) + }) + + t.Run("non-TTY run renders once", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + var buf bytes.Buffer + f := NewFrame("HCF") + f.out = &buf + f.Header(StaticModel("h")) + f.Content(StaticModel("c")) + f.Footer(StaticModel("f")) + + f.Run() // non-TTY, should return immediately + assert.Contains(t, buf.String(), "h") + assert.Contains(t, buf.String(), "c") + assert.Contains(t, buf.String(), "f") + }) + + t.Run("ModelFunc adapter", func(t *testing.T) { + called := false + m := ModelFunc(func(w, h int) string { + called = true + return "dynamic" + }) + + out := m.View(80, 24) + assert.True(t, called) + assert.Equal(t, "dynamic", out) + }) + + t.Run("RunFor exits after duration", func(t *testing.T) { + var buf bytes.Buffer + f := NewFrame("C") + f.out = &buf // non-TTY → RunFor renders once and returns + f.Content(StaticModel("timed")) + + start := time.Now() + f.RunFor(50 * time.Millisecond) + elapsed := time.Since(start) + + assert.Less(t, elapsed, 200*time.Millisecond) + assert.Contains(t, buf.String(), "timed") + }) +} + +func TestFrame_Bad(t *testing.T) { + t.Run("empty frame", func(t *testing.T) { + f := NewFrame("HCF") + f.out = &bytes.Buffer{} + assert.Equal(t, "", f.String()) + }) + + t.Run("back on empty history", func(t *testing.T) { + f := NewFrame("C") + f.out = &bytes.Buffer{} + f.Content(StaticModel("x")) + assert.False(t, f.Back()) + }) + + t.Run("invalid variant degrades gracefully", func(t *testing.T) { + f := NewFrame("XYZ") + f.out = &bytes.Buffer{} + // No valid regions, so nothing renders + assert.Equal(t, "", f.String()) + }) +} + +func TestStatusLine_Good(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + m := StatusLine("core dev", "18 repos", "main") + out := m.View(80, 1) + assert.Contains(t, out, "core dev") + assert.Contains(t, out, "18 repos") + assert.Contains(t, out, "main") +} + +func TestKeyHints_Good(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + m := KeyHints("↑/↓ navigate", "q quit") + out := m.View(80, 1) + assert.Contains(t, out, "navigate") + assert.Contains(t, out, "quit") +} + +func TestBreadcrumb_Good(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + m := Breadcrumb("core", "dev", "health") + out := m.View(80, 1) + assert.Contains(t, out, "core") + assert.Contains(t, out, "dev") + assert.Contains(t, out, "health") + assert.Contains(t, out, ">") +} + +func TestStaticModel_Good(t *testing.T) { + m := StaticModel("hello") + assert.Equal(t, "hello", m.View(80, 24)) +} + +// indexOf returns the position of substr in s, or -1 if not found. +func indexOf(s, substr string) int { + for i := range len(s) - len(substr) + 1 { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/pkg/cli/glyph.go b/pkg/cli/glyph.go new file mode 100644 index 00000000..26023e54 --- /dev/null +++ b/pkg/cli/glyph.go @@ -0,0 +1,92 @@ +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 new file mode 100644 index 00000000..0aed5b81 --- /dev/null +++ b/pkg/cli/glyph_maps.go @@ -0,0 +1,25 @@ +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 new file mode 100644 index 00000000..d43c0be2 --- /dev/null +++ b/pkg/cli/glyph_test.go @@ -0,0 +1,23 @@ +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 new file mode 100644 index 00000000..29983fa7 --- /dev/null +++ b/pkg/cli/i18n.go @@ -0,0 +1,170 @@ +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 new file mode 100644 index 00000000..a8aedbbe --- /dev/null +++ b/pkg/cli/layout.go @@ -0,0 +1,148 @@ +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 new file mode 100644 index 00000000..4fb42ada --- /dev/null +++ b/pkg/cli/layout_test.go @@ -0,0 +1,25 @@ +package cli + +import "testing" + +func TestParseVariant(t *testing.T) { + c, err := ParseVariant("H[LC]F") + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + if _, ok := c.regions[RegionHeader]; !ok { + t.Error("Expected Header region") + } + if _, ok := c.regions[RegionFooter]; !ok { + t.Error("Expected Footer region") + } + + hSlot := c.regions[RegionHeader] + if hSlot.child == nil { + t.Error("Header should have child layout") + } else { + if _, ok := hSlot.child.regions[RegionLeft]; !ok { + t.Error("Child should have Left region") + } + } +} diff --git a/pkg/cli/log.go b/pkg/cli/log.go new file mode 100644 index 00000000..893df2e2 --- /dev/null +++ b/pkg/cli/log.go @@ -0,0 +1,115 @@ +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 new file mode 100644 index 00000000..3e1662f0 --- /dev/null +++ b/pkg/cli/output.go @@ -0,0 +1,195 @@ +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 new file mode 100644 index 00000000..91a92ecc --- /dev/null +++ b/pkg/cli/output_test.go @@ -0,0 +1,101 @@ +package cli + +import ( + "bytes" + "io" + "os" + "testing" +) + +func captureOutput(f func()) string { + oldOut := os.Stdout + oldErr := os.Stderr + r, w, _ := os.Pipe() + os.Stdout = w + os.Stderr = w + + f() + + _ = w.Close() + os.Stdout = oldOut + os.Stderr = oldErr + + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func TestSemanticOutput(t *testing.T) { + UseASCII() + + // Test Success + out := captureOutput(func() { + Success("done") + }) + if out == "" { + t.Error("Success output empty") + } + + // Test Error + out = captureOutput(func() { + Error("fail") + }) + if out == "" { + t.Error("Error output empty") + } + + // Test Warn + out = captureOutput(func() { + Warn("warn") + }) + if out == "" { + t.Error("Warn output empty") + } + + // Test Info + out = captureOutput(func() { + Info("info") + }) + if out == "" { + t.Error("Info output empty") + } + + // Test Task + out = captureOutput(func() { + Task("task", "msg") + }) + if out == "" { + t.Error("Task output empty") + } + + // Test Section + out = captureOutput(func() { + Section("section") + }) + if out == "" { + t.Error("Section output empty") + } + + // Test Hint + out = captureOutput(func() { + Hint("hint", "msg") + }) + if out == "" { + t.Error("Hint output empty") + } + + // Test Result + out = captureOutput(func() { + Result(true, "pass") + }) + if out == "" { + t.Error("Result(true) output empty") + } + + out = captureOutput(func() { + Result(false, "fail") + }) + if out == "" { + t.Error("Result(false) output empty") + } +} diff --git a/pkg/cli/prompt.go b/pkg/cli/prompt.go new file mode 100644 index 00000000..d9eb993e --- /dev/null +++ b/pkg/cli/prompt.go @@ -0,0 +1,75 @@ +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 new file mode 100644 index 00000000..95bb05c6 --- /dev/null +++ b/pkg/cli/render.go @@ -0,0 +1,87 @@ +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 new file mode 100644 index 00000000..08636f18 --- /dev/null +++ b/pkg/cli/runtime.go @@ -0,0 +1,219 @@ +// Package cli provides the CLI runtime and utilities. +// +// The CLI uses the Core framework for its own runtime. Usage is simple: +// +// cli.Init(cli.Options{AppName: "core"}) +// defer cli.Shutdown() +// +// cli.Success("Done!") +// cli.Error("Failed") +// if cli.Confirm("Proceed?") { ... } +// +// // When you need the Core instance +// c := cli.Core() +package cli + +import ( + "context" + "os" + "os/signal" + "sync" + "syscall" + + "forge.lthn.ai/core/go/pkg/framework" + "github.com/spf13/cobra" +) + +var ( + instance *runtime + once sync.Once +) + +// runtime is the CLI's internal Core runtime. +type runtime struct { + core *framework.Core + root *cobra.Command + ctx context.Context + cancel context.CancelFunc +} + +// Options configures the CLI runtime. +type Options struct { + AppName string + Version string + Services []framework.Option // Additional services to register + + // OnReload is called when SIGHUP is received (daemon mode). + // Use for configuration reloading. Leave nil to ignore SIGHUP. + OnReload func() error +} + +// Init initialises the global CLI runtime. +// Call this once at startup (typically in main.go or cmd.Execute). +func Init(opts Options) error { + var initErr error + once.Do(func() { + ctx, cancel := context.WithCancel(context.Background()) + + // Create root command + rootCmd := &cobra.Command{ + Use: opts.AppName, + Version: opts.Version, + SilenceErrors: true, + SilenceUsage: true, + } + + // Attach all registered commands + attachRegisteredCommands(rootCmd) + + // Build signal service options + var signalOpts []SignalOption + if opts.OnReload != nil { + signalOpts = append(signalOpts, WithReloadHandler(opts.OnReload)) + } + + // Build options: app, signal service + any additional services + coreOpts := []framework.Option{ + framework.WithApp(rootCmd), + framework.WithName("signal", newSignalService(cancel, signalOpts...)), + } + coreOpts = append(coreOpts, opts.Services...) + coreOpts = append(coreOpts, framework.WithServiceLock()) + + c, err := framework.New(coreOpts...) + if err != nil { + initErr = err + cancel() + return + } + + instance = &runtime{ + core: c, + root: rootCmd, + ctx: ctx, + cancel: cancel, + } + + if err := c.ServiceStartup(ctx, nil); err != nil { + initErr = err + return + } + }) + return initErr +} + +func mustInit() { + if instance == nil { + panic("cli not initialised - call cli.Init() first") + } +} + +// --- Core Access --- + +// Core returns the CLI's framework Core instance. +func Core() *framework.Core { + mustInit() + return instance.core +} + +// RootCmd returns the CLI's root cobra command. +func RootCmd() *cobra.Command { + mustInit() + return instance.root +} + +// Execute runs the CLI root command. +// Returns an error if the command fails. +func Execute() error { + mustInit() + return instance.root.Execute() +} + +// Context returns the CLI's root context. +// Cancelled on SIGINT/SIGTERM. +func Context() context.Context { + mustInit() + return instance.ctx +} + +// Shutdown gracefully shuts down the CLI. +func Shutdown() { + if instance == nil { + return + } + instance.cancel() + _ = instance.core.ServiceShutdown(instance.ctx) +} + +// --- Signal Service (internal) --- + +type signalService struct { + cancel context.CancelFunc + sigChan chan os.Signal + onReload func() error + shutdownOnce sync.Once +} + +// SignalOption configures signal handling. +type SignalOption func(*signalService) + +// WithReloadHandler sets a callback for SIGHUP. +func WithReloadHandler(fn func() error) SignalOption { + return func(s *signalService) { + s.onReload = fn + } +} + +func newSignalService(cancel context.CancelFunc, opts ...SignalOption) func(*framework.Core) (any, error) { + return func(c *framework.Core) (any, error) { + svc := &signalService{ + cancel: cancel, + sigChan: make(chan os.Signal, 1), + } + for _, opt := range opts { + opt(svc) + } + return svc, nil + } +} + +func (s *signalService) OnStartup(ctx context.Context) error { + signals := []os.Signal{syscall.SIGINT, syscall.SIGTERM} + if s.onReload != nil { + signals = append(signals, syscall.SIGHUP) + } + signal.Notify(s.sigChan, signals...) + + go func() { + for { + select { + case sig := <-s.sigChan: + switch sig { + case syscall.SIGHUP: + if s.onReload != nil { + if err := s.onReload(); err != nil { + LogError("reload failed", "err", err) + } else { + LogInfo("configuration reloaded") + } + } + case syscall.SIGINT, syscall.SIGTERM: + s.cancel() + return + } + case <-ctx.Done(): + return + } + } + }() + + return nil +} + +func (s *signalService) OnShutdown(ctx context.Context) error { + s.shutdownOnce.Do(func() { + signal.Stop(s.sigChan) + close(s.sigChan) + }) + return nil +} diff --git a/pkg/cli/stream.go b/pkg/cli/stream.go new file mode 100644 index 00000000..e12aa4b2 --- /dev/null +++ b/pkg/cli/stream.go @@ -0,0 +1,140 @@ +package cli + +import ( + "fmt" + "io" + "os" + "strings" + "sync" + "unicode/utf8" +) + +// StreamOption configures a Stream. +type StreamOption func(*Stream) + +// WithWordWrap sets the word-wrap column width. +func WithWordWrap(cols int) StreamOption { + return func(s *Stream) { s.wrap = cols } +} + +// WithStreamOutput sets the output writer (default: os.Stdout). +func WithStreamOutput(w io.Writer) StreamOption { + return func(s *Stream) { s.out = w } +} + +// Stream renders growing text as tokens arrive, with optional word-wrap. +// Safe for concurrent writes from a single producer goroutine. +// +// stream := cli.NewStream(cli.WithWordWrap(80)) +// go func() { +// for token := range tokens { +// stream.Write(token) +// } +// stream.Done() +// }() +// stream.Wait() +type Stream struct { + out io.Writer + wrap int + col int // current column position (visible characters) + done chan struct{} + mu sync.Mutex +} + +// NewStream creates a streaming text renderer. +func NewStream(opts ...StreamOption) *Stream { + s := &Stream{ + out: os.Stdout, + done: make(chan struct{}), + } + for _, opt := range opts { + opt(s) + } + return s +} + +// Write appends text to the stream. Handles word-wrap if configured. +func (s *Stream) Write(text string) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.wrap <= 0 { + fmt.Fprint(s.out, text) + // Track column across newlines for Done() trailing-newline logic. + if idx := strings.LastIndex(text, "\n"); idx >= 0 { + s.col = utf8.RuneCountInString(text[idx+1:]) + } else { + s.col += utf8.RuneCountInString(text) + } + return + } + + for _, r := range text { + if r == '\n' { + fmt.Fprintln(s.out) + s.col = 0 + continue + } + + if s.col >= s.wrap { + fmt.Fprintln(s.out) + s.col = 0 + } + + fmt.Fprint(s.out, string(r)) + s.col++ + } +} + +// WriteFrom reads from r and streams all content until EOF. +func (s *Stream) WriteFrom(r io.Reader) error { + buf := make([]byte, 256) + for { + n, err := r.Read(buf) + if n > 0 { + s.Write(string(buf[:n])) + } + if err == io.EOF { + return nil + } + if err != nil { + return err + } + } +} + +// Done signals that no more text will arrive. +func (s *Stream) Done() { + s.mu.Lock() + if s.col > 0 { + fmt.Fprintln(s.out) // ensure trailing newline + } + s.mu.Unlock() + close(s.done) +} + +// Wait blocks until Done is called. +func (s *Stream) Wait() { + <-s.done +} + +// Content returns the current column position (for testing). +func (s *Stream) Column() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.col +} + +// Captured returns the stream output as a string when using a bytes.Buffer. +// Panics if the output writer is not a *strings.Builder or fmt.Stringer. +func (s *Stream) Captured() string { + s.mu.Lock() + defer s.mu.Unlock() + if sb, ok := s.out.(*strings.Builder); ok { + return sb.String() + } + if st, ok := s.out.(fmt.Stringer); ok { + return st.String() + } + return "" +} diff --git a/pkg/cli/stream_test.go b/pkg/cli/stream_test.go new file mode 100644 index 00000000..822a13c3 --- /dev/null +++ b/pkg/cli/stream_test.go @@ -0,0 +1,159 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStream_Good(t *testing.T) { + t.Run("basic write", func(t *testing.T) { + var buf bytes.Buffer + s := NewStream(WithStreamOutput(&buf)) + + s.Write("hello ") + s.Write("world") + s.Done() + s.Wait() + + assert.Equal(t, "hello world\n", buf.String()) + }) + + t.Run("write with newlines", func(t *testing.T) { + var buf bytes.Buffer + s := NewStream(WithStreamOutput(&buf)) + + s.Write("line1\nline2\n") + s.Done() + s.Wait() + + assert.Equal(t, "line1\nline2\n", buf.String()) + }) + + t.Run("word wrap", func(t *testing.T) { + var buf bytes.Buffer + s := NewStream(WithWordWrap(10), WithStreamOutput(&buf)) + + s.Write("1234567890ABCDE") + s.Done() + s.Wait() + + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + assert.Equal(t, 2, len(lines)) + assert.Equal(t, "1234567890", lines[0]) + assert.Equal(t, "ABCDE", lines[1]) + }) + + t.Run("word wrap preserves explicit newlines", func(t *testing.T) { + var buf bytes.Buffer + s := NewStream(WithWordWrap(20), WithStreamOutput(&buf)) + + s.Write("short\nanother") + s.Done() + s.Wait() + + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + assert.Equal(t, 2, len(lines)) + assert.Equal(t, "short", lines[0]) + assert.Equal(t, "another", lines[1]) + }) + + t.Run("word wrap resets column on newline", func(t *testing.T) { + var buf bytes.Buffer + s := NewStream(WithWordWrap(5), WithStreamOutput(&buf)) + + s.Write("12345\n67890ABCDE") + s.Done() + s.Wait() + + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + assert.Equal(t, 3, len(lines)) + assert.Equal(t, "12345", lines[0]) + assert.Equal(t, "67890", lines[1]) + assert.Equal(t, "ABCDE", lines[2]) + }) + + t.Run("no wrap when disabled", func(t *testing.T) { + var buf bytes.Buffer + s := NewStream(WithStreamOutput(&buf)) + + s.Write(strings.Repeat("x", 200)) + s.Done() + s.Wait() + + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + assert.Equal(t, 1, len(lines)) + assert.Equal(t, 200, len(lines[0])) + }) + + t.Run("column tracking", func(t *testing.T) { + var buf bytes.Buffer + s := NewStream(WithStreamOutput(&buf)) + + s.Write("hello") + assert.Equal(t, 5, s.Column()) + + s.Write(" world") + assert.Equal(t, 11, s.Column()) + }) + + t.Run("WriteFrom io.Reader", func(t *testing.T) { + var buf bytes.Buffer + s := NewStream(WithStreamOutput(&buf)) + + r := strings.NewReader("streamed content") + err := s.WriteFrom(r) + assert.NoError(t, err) + + s.Done() + s.Wait() + + assert.Equal(t, "streamed content\n", buf.String()) + }) + + t.Run("channel pattern", func(t *testing.T) { + var buf bytes.Buffer + s := NewStream(WithStreamOutput(&buf)) + + tokens := make(chan string, 3) + tokens <- "one " + tokens <- "two " + tokens <- "three" + close(tokens) + + go func() { + for tok := range tokens { + s.Write(tok) + } + s.Done() + }() + + s.Wait() + assert.Equal(t, "one two three\n", buf.String()) + }) + + t.Run("Done adds trailing newline only if needed", func(t *testing.T) { + var buf bytes.Buffer + s := NewStream(WithStreamOutput(&buf)) + + s.Write("text\n") // ends with newline, col=0 + s.Done() + s.Wait() + + assert.Equal(t, "text\n", buf.String()) // no double newline + }) +} + +func TestStream_Bad(t *testing.T) { + t.Run("empty stream", func(t *testing.T) { + var buf bytes.Buffer + s := NewStream(WithStreamOutput(&buf)) + + s.Done() + s.Wait() + + assert.Equal(t, "", buf.String()) + }) +} diff --git a/pkg/cli/strings.go b/pkg/cli/strings.go new file mode 100644 index 00000000..1e587ad8 --- /dev/null +++ b/pkg/cli/strings.go @@ -0,0 +1,48 @@ +package cli + +import "fmt" + +// Sprintf formats a string (fmt.Sprintf wrapper). +func Sprintf(format string, args ...any) string { + return fmt.Sprintf(format, args...) +} + +// Sprint formats using default formats (fmt.Sprint wrapper). +func Sprint(args ...any) string { + return fmt.Sprint(args...) +} + +// Styled returns text with a style applied. +func Styled(style *AnsiStyle, text string) string { + return style.Render(text) +} + +// Styledf returns formatted text with a style applied. +func Styledf(style *AnsiStyle, format string, args ...any) string { + return style.Render(fmt.Sprintf(format, args...)) +} + +// SuccessStr returns success-styled string. +func SuccessStr(msg string) string { + return SuccessStyle.Render(Glyph(":check:") + " " + msg) +} + +// ErrorStr returns error-styled string. +func ErrorStr(msg string) string { + return ErrorStyle.Render(Glyph(":cross:") + " " + msg) +} + +// WarnStr returns warning-styled string. +func WarnStr(msg string) string { + return WarningStyle.Render(Glyph(":warn:") + " " + msg) +} + +// InfoStr returns info-styled string. +func InfoStr(msg string) string { + return InfoStyle.Render(Glyph(":info:") + " " + msg) +} + +// DimStr returns dim-styled string. +func DimStr(msg string) string { + return DimStyle.Render(msg) +} diff --git a/pkg/cli/styles.go b/pkg/cli/styles.go new file mode 100644 index 00000000..e5c40864 --- /dev/null +++ b/pkg/cli/styles.go @@ -0,0 +1,440 @@ +// 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))) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Border Styles +// ───────────────────────────────────────────────────────────────────────────── + +// BorderStyle selects the box-drawing character set for table borders. +type BorderStyle int + +const ( + // BorderNone disables borders (default). + BorderNone BorderStyle = iota + // BorderNormal uses standard box-drawing: ┌─┬┐ │ ├─┼┤ └─┴┘ + BorderNormal + // BorderRounded uses rounded corners: ╭─┬╮ │ ├─┼┤ ╰─┴╯ + BorderRounded + // BorderHeavy uses heavy box-drawing: ┏━┳┓ ┃ ┣━╋┫ ┗━┻┛ + BorderHeavy + // BorderDouble uses double-line box-drawing: ╔═╦╗ ║ ╠═╬╣ ╚═╩╝ + BorderDouble +) + +type borderSet struct { + tl, tr, bl, br string // corners + h, v string // horizontal, vertical + tt, bt, lt, rt string // tees (top, bottom, left, right) + x string // cross +} + +var borderSets = map[BorderStyle]borderSet{ + BorderNormal: {"┌", "┐", "└", "┘", "─", "│", "┬", "┴", "├", "┤", "┼"}, + BorderRounded: {"╭", "╮", "╰", "╯", "─", "│", "┬", "┴", "├", "┤", "┼"}, + BorderHeavy: {"┏", "┓", "┗", "┛", "━", "┃", "┳", "┻", "┣", "┫", "╋"}, + BorderDouble: {"╔", "╗", "╚", "╝", "═", "║", "╦", "╩", "╠", "╣", "╬"}, +} + +// CellStyleFn returns a style based on the cell's raw value. +// Return nil to use the table's default CellStyle. +type CellStyleFn func(value string) *AnsiStyle + +// ───────────────────────────────────────────────────────────────────────────── +// Table +// ───────────────────────────────────────────────────────────────────────────── + +// Table renders tabular data with aligned columns. +// Supports optional box-drawing borders and per-column cell styling. +// +// t := cli.NewTable("REPO", "STATUS", "BRANCH"). +// WithBorders(cli.BorderRounded). +// WithCellStyle(1, func(val string) *cli.AnsiStyle { +// if val == "clean" { return cli.SuccessStyle } +// return cli.WarningStyle +// }) +// t.AddRow("core-php", "clean", "main") +// t.Render() +type Table struct { + Headers []string + Rows [][]string + Style TableStyle + borders BorderStyle + cellStyleFns map[int]CellStyleFn + maxWidth int +} + +// 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 +} + +// WithBorders enables box-drawing borders on the table. +func (t *Table) WithBorders(style BorderStyle) *Table { + t.borders = style + return t +} + +// WithCellStyle sets a per-column style function. +// The function receives the raw cell value and returns a style. +func (t *Table) WithCellStyle(col int, fn CellStyleFn) *Table { + if t.cellStyleFns == nil { + t.cellStyleFns = make(map[int]CellStyleFn) + } + t.cellStyleFns[col] = fn + return t +} + +// WithMaxWidth sets the maximum table width, truncating columns to fit. +func (t *Table) WithMaxWidth(w int) *Table { + t.maxWidth = w + return t +} + +// String renders the table. +func (t *Table) String() string { + if len(t.Headers) == 0 && len(t.Rows) == 0 { + return "" + } + + if t.borders != BorderNone { + return t.renderBordered() + } + return t.renderPlain() +} + +// Render prints the table to stdout. +func (t *Table) Render() { + fmt.Print(t.String()) +} + +func (t *Table) colCount() int { + cols := len(t.Headers) + if cols == 0 && len(t.Rows) > 0 { + cols = len(t.Rows[0]) + } + return cols +} + +func (t *Table) columnWidths() []int { + cols := t.colCount() + 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) + } + } + } + + if t.maxWidth > 0 { + t.constrainWidths(widths) + } + return widths +} + +func (t *Table) constrainWidths(widths []int) { + cols := len(widths) + overhead := 0 + if t.borders != BorderNone { + // │ cell │ cell │ = (cols+1) verticals + 2*cols padding spaces + overhead = (cols + 1) + (cols * 2) + } else { + // separator between columns + overhead = (cols - 1) * len(t.Style.Separator) + } + + total := overhead + for _, w := range widths { + total += w + } + + if total <= t.maxWidth { + return + } + + // Shrink widest columns first until we fit. + budget := t.maxWidth - overhead + if budget < cols { + budget = cols + } + for total-overhead > budget { + maxIdx, maxW := 0, 0 + for i, w := range widths { + if w > maxW { + maxIdx, maxW = i, w + } + } + widths[maxIdx]-- + total-- + } +} + +func (t *Table) resolveStyle(col int, value string) *AnsiStyle { + if t.cellStyleFns != nil { + if fn, ok := t.cellStyleFns[col]; ok { + if s := fn(value); s != nil { + return s + } + } + } + return t.Style.CellStyle +} + +func (t *Table) renderPlain() string { + widths := t.columnWidths() + + var sb strings.Builder + sep := t.Style.Separator + + if len(t.Headers) > 0 { + for i, h := range t.Headers { + if i > 0 { + sb.WriteString(sep) + } + cell := Pad(Truncate(h, widths[i]), widths[i]) + if t.Style.HeaderStyle != nil { + cell = t.Style.HeaderStyle.Render(cell) + } + sb.WriteString(cell) + } + sb.WriteByte('\n') + } + + for _, row := range t.Rows { + for i := range t.colCount() { + if i > 0 { + sb.WriteString(sep) + } + val := "" + if i < len(row) { + val = row[i] + } + cell := Pad(Truncate(val, widths[i]), widths[i]) + if style := t.resolveStyle(i, val); style != nil { + cell = style.Render(cell) + } + sb.WriteString(cell) + } + sb.WriteByte('\n') + } + + return sb.String() +} + +func (t *Table) renderBordered() string { + b := borderSets[t.borders] + widths := t.columnWidths() + cols := t.colCount() + + var sb strings.Builder + + // Top border: ╭──────┬──────╮ + sb.WriteString(b.tl) + for i := range cols { + sb.WriteString(strings.Repeat(b.h, widths[i]+2)) + if i < cols-1 { + sb.WriteString(b.tt) + } + } + sb.WriteString(b.tr) + sb.WriteByte('\n') + + // Header row + if len(t.Headers) > 0 { + sb.WriteString(b.v) + for i := range cols { + h := "" + if i < len(t.Headers) { + h = t.Headers[i] + } + cell := Pad(Truncate(h, widths[i]), widths[i]) + if t.Style.HeaderStyle != nil { + cell = t.Style.HeaderStyle.Render(cell) + } + sb.WriteByte(' ') + sb.WriteString(cell) + sb.WriteByte(' ') + sb.WriteString(b.v) + } + sb.WriteByte('\n') + + // Header separator: ├──────┼──────┤ + sb.WriteString(b.lt) + for i := range cols { + sb.WriteString(strings.Repeat(b.h, widths[i]+2)) + if i < cols-1 { + sb.WriteString(b.x) + } + } + sb.WriteString(b.rt) + sb.WriteByte('\n') + } + + // Data rows + for _, row := range t.Rows { + sb.WriteString(b.v) + for i := range cols { + val := "" + if i < len(row) { + val = row[i] + } + cell := Pad(Truncate(val, widths[i]), widths[i]) + if style := t.resolveStyle(i, val); style != nil { + cell = style.Render(cell) + } + sb.WriteByte(' ') + sb.WriteString(cell) + sb.WriteByte(' ') + sb.WriteString(b.v) + } + sb.WriteByte('\n') + } + + // Bottom border: ╰──────┴──────╯ + sb.WriteString(b.bl) + for i := range cols { + sb.WriteString(strings.Repeat(b.h, widths[i]+2)) + if i < cols-1 { + sb.WriteString(b.bt) + } + } + sb.WriteString(b.br) + sb.WriteByte('\n') + + return sb.String() +} diff --git a/pkg/cli/styles_test.go b/pkg/cli/styles_test.go new file mode 100644 index 00000000..0ac02bc6 --- /dev/null +++ b/pkg/cli/styles_test.go @@ -0,0 +1,206 @@ +package cli + +import ( + "strings" + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/assert" +) + +func TestTable_Good(t *testing.T) { + t.Run("plain table unchanged", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + tbl := NewTable("NAME", "AGE") + tbl.AddRow("Alice", "30") + tbl.AddRow("Bob", "25") + + out := tbl.String() + assert.Contains(t, out, "NAME") + assert.Contains(t, out, "Alice") + assert.Contains(t, out, "Bob") + }) + + t.Run("bordered normal", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + tbl := NewTable("A", "B").WithBorders(BorderNormal) + tbl.AddRow("x", "y") + + out := tbl.String() + assert.True(t, strings.HasPrefix(out, "┌")) + assert.Contains(t, out, "┐") + assert.Contains(t, out, "│") + assert.Contains(t, out, "├") + assert.Contains(t, out, "┤") + assert.Contains(t, out, "└") + assert.Contains(t, out, "┘") + }) + + t.Run("bordered rounded", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + tbl := NewTable("REPO", "STATUS").WithBorders(BorderRounded) + tbl.AddRow("core", "clean") + + out := tbl.String() + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + assert.True(t, strings.HasPrefix(lines[0], "╭")) + assert.True(t, strings.HasSuffix(lines[0], "╮")) + assert.True(t, strings.HasPrefix(lines[len(lines)-1], "╰")) + assert.True(t, strings.HasSuffix(lines[len(lines)-1], "╯")) + }) + + t.Run("bordered heavy", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + tbl := NewTable("X").WithBorders(BorderHeavy) + tbl.AddRow("v") + + out := tbl.String() + assert.Contains(t, out, "┏") + assert.Contains(t, out, "┓") + assert.Contains(t, out, "┃") + }) + + t.Run("bordered double", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + tbl := NewTable("X").WithBorders(BorderDouble) + tbl.AddRow("v") + + out := tbl.String() + assert.Contains(t, out, "╔") + assert.Contains(t, out, "╗") + assert.Contains(t, out, "║") + }) + + t.Run("bordered structure", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + tbl := NewTable("A", "B").WithBorders(BorderRounded) + tbl.AddRow("x", "y") + tbl.AddRow("1", "2") + + lines := strings.Split(strings.TrimRight(tbl.String(), "\n"), "\n") + // Top border, header, separator, 2 data rows, bottom border = 6 lines + assert.Equal(t, 6, len(lines), "expected 6 lines: border, header, sep, 2 rows, border") + }) + + t.Run("cell style function", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + called := false + tbl := NewTable("STATUS"). + WithCellStyle(0, func(val string) *AnsiStyle { + called = true + if val == "ok" { + return SuccessStyle + } + return ErrorStyle + }) + tbl.AddRow("ok") + tbl.AddRow("fail") + + _ = tbl.String() + assert.True(t, called, "cell style function should be called") + }) + + t.Run("cell style with borders", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + tbl := NewTable("NAME", "STATUS"). + WithBorders(BorderRounded). + WithCellStyle(1, func(val string) *AnsiStyle { + return nil // fallback to default + }) + tbl.AddRow("core", "ok") + + out := tbl.String() + assert.Contains(t, out, "core") + assert.Contains(t, out, "ok") + }) + + t.Run("max width truncates", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + tbl := NewTable("LONG_HEADER", "SHORT").WithMaxWidth(25) + tbl.AddRow("very_long_value_here", "x") + + out := tbl.String() + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + for _, line := range lines { + w := utf8.RuneCountInString(line) + assert.LessOrEqual(t, w, 25, "line should not exceed max width: %q", line) + } + }) + + t.Run("max width with borders", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + tbl := NewTable("A", "B").WithBorders(BorderNormal).WithMaxWidth(20) + tbl.AddRow("hello", "world") + + out := tbl.String() + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + for _, line := range lines { + w := utf8.RuneCountInString(line) + assert.LessOrEqual(t, w, 20, "bordered line should not exceed max width: %q", line) + } + }) + + t.Run("empty table returns empty", func(t *testing.T) { + tbl := NewTable() + assert.Equal(t, "", tbl.String()) + }) + + t.Run("no headers with borders", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + tbl := NewTable().WithBorders(BorderNormal) + tbl.Rows = [][]string{{"a", "b"}, {"c", "d"}} + + out := tbl.String() + assert.Contains(t, out, "┌") + // No header separator since no headers + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + // Top border, 2 data rows, bottom border = 4 lines (no header separator) + assert.Equal(t, 4, len(lines)) + }) +} + +func TestTable_Bad(t *testing.T) { + t.Run("short rows padded", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + tbl := NewTable("A", "B", "C") + tbl.AddRow("x") // only 1 cell, 3 columns + + out := tbl.String() + assert.Contains(t, out, "x") + }) +} + +func TestTruncate_Good(t *testing.T) { + assert.Equal(t, "hel...", Truncate("hello world", 6)) + assert.Equal(t, "hi", Truncate("hi", 6)) + assert.Equal(t, "he", Truncate("hello", 2)) +} + +func TestPad_Good(t *testing.T) { + assert.Equal(t, "hi ", Pad("hi", 5)) + assert.Equal(t, "hello", Pad("hello", 3)) +} diff --git a/pkg/cli/tracker.go b/pkg/cli/tracker.go new file mode 100644 index 00000000..b8e4192d --- /dev/null +++ b/pkg/cli/tracker.go @@ -0,0 +1,291 @@ +package cli + +import ( + "fmt" + "io" + "os" + "strings" + "sync" + "time" + + "golang.org/x/term" +) + +// Spinner frames (braille pattern). +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// taskState tracks the lifecycle of a tracked task. +type taskState int + +const ( + taskPending taskState = iota + taskRunning + taskDone + taskFailed +) + +// TrackedTask represents a single task in a TaskTracker. +// Safe for concurrent use — call Update, Done, or Fail from any goroutine. +type TrackedTask struct { + name string + status string + state taskState + tracker *TaskTracker + mu sync.Mutex +} + +// Update sets the task status message and marks it as running. +func (t *TrackedTask) Update(status string) { + t.mu.Lock() + t.status = status + t.state = taskRunning + t.mu.Unlock() +} + +// Done marks the task as successfully completed with a final message. +func (t *TrackedTask) Done(message string) { + t.mu.Lock() + t.status = message + t.state = taskDone + t.mu.Unlock() +} + +// Fail marks the task as failed with an error message. +func (t *TrackedTask) Fail(message string) { + t.mu.Lock() + t.status = message + t.state = taskFailed + t.mu.Unlock() +} + +func (t *TrackedTask) snapshot() (string, string, taskState) { + t.mu.Lock() + defer t.mu.Unlock() + return t.name, t.status, t.state +} + +// TaskTracker displays multiple concurrent tasks with individual spinners. +// +// tracker := cli.NewTaskTracker() +// for _, repo := range repos { +// t := tracker.Add(repo.Name) +// go func(t *cli.TrackedTask) { +// t.Update("pulling...") +// // ... +// t.Done("up to date") +// }(t) +// } +// tracker.Wait() +type TaskTracker struct { + tasks []*TrackedTask + out io.Writer + mu sync.Mutex + started bool +} + +// NewTaskTracker creates a new parallel task tracker. +func NewTaskTracker() *TaskTracker { + return &TaskTracker{out: os.Stdout} +} + +// Add registers a task and returns it for goroutine use. +func (tr *TaskTracker) Add(name string) *TrackedTask { + t := &TrackedTask{ + name: name, + status: "waiting", + state: taskPending, + tracker: tr, + } + tr.mu.Lock() + tr.tasks = append(tr.tasks, t) + tr.mu.Unlock() + return t +} + +// Wait renders the task display and blocks until all tasks complete. +// Uses ANSI cursor manipulation for live updates when connected to a terminal. +// Falls back to line-by-line output for non-TTY. +func (tr *TaskTracker) Wait() { + if !tr.isTTY() { + tr.waitStatic() + return + } + tr.waitLive() +} + +func (tr *TaskTracker) isTTY() bool { + if f, ok := tr.out.(*os.File); ok { + return term.IsTerminal(int(f.Fd())) + } + return false +} + +func (tr *TaskTracker) waitStatic() { + // Non-TTY: print final state of each task when it completes. + reported := make(map[int]bool) + for { + tr.mu.Lock() + tasks := tr.tasks + tr.mu.Unlock() + + allDone := true + for i, t := range tasks { + name, status, state := t.snapshot() + if state != taskDone && state != taskFailed { + allDone = false + continue + } + if reported[i] { + continue + } + reported[i] = true + icon := Glyph(":check:") + if state == taskFailed { + icon = Glyph(":cross:") + } + fmt.Fprintf(tr.out, "%s %-20s %s\n", icon, name, status) + } + if allDone { + return + } + time.Sleep(50 * time.Millisecond) + } +} + +func (tr *TaskTracker) waitLive() { + tr.mu.Lock() + n := len(tr.tasks) + tr.mu.Unlock() + + // Print initial lines. + frame := 0 + for i := range n { + tr.renderLine(i, frame) + } + + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + + for { + <-ticker.C + frame++ + + tr.mu.Lock() + count := len(tr.tasks) + tr.mu.Unlock() + + // Move cursor up to redraw all lines. + fmt.Fprintf(tr.out, "\033[%dA", count) + for i := range count { + tr.renderLine(i, frame) + } + + if tr.allDone() { + return + } + } +} + +func (tr *TaskTracker) renderLine(idx, frame int) { + tr.mu.Lock() + t := tr.tasks[idx] + tr.mu.Unlock() + + name, status, state := t.snapshot() + nameW := tr.nameWidth() + + var icon string + switch state { + case taskPending: + icon = DimStyle.Render(Glyph(":pending:")) + case taskRunning: + icon = InfoStyle.Render(spinnerFrames[frame%len(spinnerFrames)]) + case taskDone: + icon = SuccessStyle.Render(Glyph(":check:")) + case taskFailed: + icon = ErrorStyle.Render(Glyph(":cross:")) + } + + var styledStatus string + switch state { + case taskDone: + styledStatus = SuccessStyle.Render(status) + case taskFailed: + styledStatus = ErrorStyle.Render(status) + default: + styledStatus = DimStyle.Render(status) + } + + fmt.Fprintf(tr.out, "\033[2K%s %-*s %s\n", icon, nameW, name, styledStatus) +} + +func (tr *TaskTracker) nameWidth() int { + tr.mu.Lock() + defer tr.mu.Unlock() + w := 0 + for _, t := range tr.tasks { + if len(t.name) > w { + w = len(t.name) + } + } + return w +} + +func (tr *TaskTracker) allDone() bool { + tr.mu.Lock() + defer tr.mu.Unlock() + for _, t := range tr.tasks { + _, _, state := t.snapshot() + if state != taskDone && state != taskFailed { + return false + } + } + return true +} + +// Summary returns a one-line summary of task results. +func (tr *TaskTracker) Summary() string { + tr.mu.Lock() + defer tr.mu.Unlock() + + var passed, failed int + for _, t := range tr.tasks { + _, _, state := t.snapshot() + switch state { + case taskDone: + passed++ + case taskFailed: + failed++ + } + } + + total := len(tr.tasks) + if failed > 0 { + return fmt.Sprintf("%d/%d passed, %d failed", passed, total, failed) + } + return fmt.Sprintf("%d/%d passed", passed, total) +} + +// String returns the current state of all tasks as plain text (no ANSI). +func (tr *TaskTracker) String() string { + tr.mu.Lock() + tasks := tr.tasks + tr.mu.Unlock() + + nameW := tr.nameWidth() + var sb strings.Builder + for _, t := range tasks { + name, status, state := t.snapshot() + icon := "…" + switch state { + case taskDone: + icon = "✓" + case taskFailed: + icon = "✗" + case taskRunning: + icon = "⠋" + } + fmt.Fprintf(&sb, "%s %-*s %s\n", icon, nameW, name, status) + } + return sb.String() +} diff --git a/pkg/cli/tracker_test.go b/pkg/cli/tracker_test.go new file mode 100644 index 00000000..df16a8bb --- /dev/null +++ b/pkg/cli/tracker_test.go @@ -0,0 +1,188 @@ +package cli + +import ( + "bytes" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTaskTracker_Good(t *testing.T) { + t.Run("add and complete tasks", func(t *testing.T) { + tr := NewTaskTracker() + tr.out = &bytes.Buffer{} // non-TTY + + t1 := tr.Add("repo-a") + t2 := tr.Add("repo-b") + + t1.Update("pulling...") + t2.Update("pulling...") + + t1.Done("up to date") + t2.Done("3 commits behind") + + out := tr.String() + assert.Contains(t, out, "repo-a") + assert.Contains(t, out, "repo-b") + assert.Contains(t, out, "up to date") + assert.Contains(t, out, "3 commits behind") + }) + + t.Run("task states", func(t *testing.T) { + tr := NewTaskTracker() + tr.out = &bytes.Buffer{} + + task := tr.Add("test") + + // Pending + _, _, state := task.snapshot() + assert.Equal(t, taskPending, state) + + // Running + task.Update("working") + _, status, state := task.snapshot() + assert.Equal(t, taskRunning, state) + assert.Equal(t, "working", status) + + // Done + task.Done("finished") + _, status, state = task.snapshot() + assert.Equal(t, taskDone, state) + assert.Equal(t, "finished", status) + }) + + t.Run("task fail", func(t *testing.T) { + tr := NewTaskTracker() + tr.out = &bytes.Buffer{} + + task := tr.Add("bad-repo") + task.Fail("connection refused") + + _, status, state := task.snapshot() + assert.Equal(t, taskFailed, state) + assert.Equal(t, "connection refused", status) + }) + + t.Run("concurrent updates", func(t *testing.T) { + tr := NewTaskTracker() + tr.out = &bytes.Buffer{} + + var wg sync.WaitGroup + for i := range 10 { + task := tr.Add("task-" + string(rune('a'+i))) + wg.Add(1) + go func(t *TrackedTask) { + defer wg.Done() + t.Update("running") + time.Sleep(5 * time.Millisecond) + t.Done("ok") + }(task) + } + wg.Wait() + + assert.True(t, tr.allDone()) + }) + + t.Run("summary all passed", func(t *testing.T) { + tr := NewTaskTracker() + tr.out = &bytes.Buffer{} + + tr.Add("a").Done("ok") + tr.Add("b").Done("ok") + tr.Add("c").Done("ok") + + assert.Equal(t, "3/3 passed", tr.Summary()) + }) + + t.Run("summary with failures", func(t *testing.T) { + tr := NewTaskTracker() + tr.out = &bytes.Buffer{} + + tr.Add("a").Done("ok") + tr.Add("b").Fail("error") + tr.Add("c").Done("ok") + + assert.Equal(t, "2/3 passed, 1 failed", tr.Summary()) + }) + + t.Run("wait completes for non-TTY", func(t *testing.T) { + var buf bytes.Buffer + tr := NewTaskTracker() + tr.out = &buf + + task := tr.Add("quick") + go func() { + time.Sleep(10 * time.Millisecond) + task.Done("done") + }() + + tr.Wait() + assert.Contains(t, buf.String(), "quick") + assert.Contains(t, buf.String(), "done") + }) + + t.Run("name width alignment", func(t *testing.T) { + tr := NewTaskTracker() + tr.out = &bytes.Buffer{} + + tr.Add("short") + tr.Add("very-long-repo-name") + + w := tr.nameWidth() + assert.Equal(t, 19, w) + }) + + t.Run("String output format", func(t *testing.T) { + tr := NewTaskTracker() + tr.out = &bytes.Buffer{} + + tr.Add("repo-a").Done("clean") + tr.Add("repo-b").Fail("dirty") + tr.Add("repo-c").Update("pulling") + + out := tr.String() + assert.Contains(t, out, "✓") + assert.Contains(t, out, "✗") + assert.Contains(t, out, "⠋") + }) +} + +func TestTaskTracker_Bad(t *testing.T) { + t.Run("allDone with no tasks", func(t *testing.T) { + tr := NewTaskTracker() + tr.out = &bytes.Buffer{} + assert.True(t, tr.allDone()) + }) + + t.Run("allDone incomplete", func(t *testing.T) { + tr := NewTaskTracker() + tr.out = &bytes.Buffer{} + tr.Add("pending") + assert.False(t, tr.allDone()) + }) +} + +func TestTrackedTask_Good(t *testing.T) { + t.Run("thread safety", func(t *testing.T) { + tr := NewTaskTracker() + tr.out = &bytes.Buffer{} + task := tr.Add("concurrent") + + var wg sync.WaitGroup + for range 100 { + wg.Add(1) + go func() { + defer wg.Done() + task.Update("running") + }() + } + wg.Wait() + + _, status, state := task.snapshot() + require.Equal(t, taskRunning, state) + require.Equal(t, "running", status) + }) +} diff --git a/pkg/cli/tree.go b/pkg/cli/tree.go new file mode 100644 index 00000000..50b4c9a9 --- /dev/null +++ b/pkg/cli/tree.go @@ -0,0 +1,98 @@ +package cli + +import ( + "fmt" + "strings" +) + +// TreeNode represents a node in a displayable tree structure. +// Use NewTree to create a root, then Add children. +// +// tree := cli.NewTree("core-php") +// tree.Add("core-tenant").Add("core-bio") +// tree.Add("core-admin") +// tree.Add("core-api") +// fmt.Print(tree) +// // core-php +// // ├── core-tenant +// // │ └── core-bio +// // ├── core-admin +// // └── core-api +type TreeNode struct { + label string + style *AnsiStyle + children []*TreeNode +} + +// NewTree creates a new tree with the given root label. +func NewTree(label string) *TreeNode { + return &TreeNode{label: label} +} + +// Add appends a child node and returns the child for chaining. +func (n *TreeNode) Add(label string) *TreeNode { + child := &TreeNode{label: label} + n.children = append(n.children, child) + return child +} + +// AddStyled appends a styled child node and returns the child for chaining. +func (n *TreeNode) AddStyled(label string, style *AnsiStyle) *TreeNode { + child := &TreeNode{label: label, style: style} + n.children = append(n.children, child) + return child +} + +// AddTree appends an existing tree as a child and returns the parent for chaining. +func (n *TreeNode) AddTree(child *TreeNode) *TreeNode { + n.children = append(n.children, child) + return n +} + +// WithStyle sets the style on this node and returns it for chaining. +func (n *TreeNode) WithStyle(style *AnsiStyle) *TreeNode { + n.style = style + return n +} + +// String renders the tree with box-drawing characters. +// Implements fmt.Stringer. +func (n *TreeNode) String() string { + var sb strings.Builder + sb.WriteString(n.renderLabel()) + sb.WriteByte('\n') + n.writeChildren(&sb, "") + return sb.String() +} + +// Render prints the tree to stdout. +func (n *TreeNode) Render() { + fmt.Print(n.String()) +} + +func (n *TreeNode) renderLabel() string { + if n.style != nil { + return n.style.Render(n.label) + } + return n.label +} + +func (n *TreeNode) writeChildren(sb *strings.Builder, prefix string) { + for i, child := range n.children { + last := i == len(n.children)-1 + + connector := "├── " + next := "│ " + if last { + connector = "└── " + next = " " + } + + sb.WriteString(prefix) + sb.WriteString(connector) + sb.WriteString(child.renderLabel()) + sb.WriteByte('\n') + + child.writeChildren(sb, prefix+next) + } +} diff --git a/pkg/cli/tree_test.go b/pkg/cli/tree_test.go new file mode 100644 index 00000000..0efdc5d1 --- /dev/null +++ b/pkg/cli/tree_test.go @@ -0,0 +1,113 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTree_Good(t *testing.T) { + t.Run("single root", func(t *testing.T) { + tree := NewTree("root") + assert.Equal(t, "root\n", tree.String()) + }) + + t.Run("flat children", func(t *testing.T) { + tree := NewTree("root") + tree.Add("alpha") + tree.Add("beta") + tree.Add("gamma") + + expected := "root\n" + + "├── alpha\n" + + "├── beta\n" + + "└── gamma\n" + assert.Equal(t, expected, tree.String()) + }) + + t.Run("nested children", func(t *testing.T) { + tree := NewTree("core-php") + tree.Add("core-tenant").Add("core-bio") + tree.Add("core-admin") + tree.Add("core-api") + + expected := "core-php\n" + + "├── core-tenant\n" + + "│ └── core-bio\n" + + "├── core-admin\n" + + "└── core-api\n" + assert.Equal(t, expected, tree.String()) + }) + + t.Run("deep nesting", func(t *testing.T) { + tree := NewTree("a") + tree.Add("b").Add("c").Add("d") + + expected := "a\n" + + "└── b\n" + + " └── c\n" + + " └── d\n" + assert.Equal(t, expected, tree.String()) + }) + + t.Run("mixed depth", func(t *testing.T) { + tree := NewTree("root") + a := tree.Add("a") + a.Add("a1") + a.Add("a2") + tree.Add("b") + + expected := "root\n" + + "├── a\n" + + "│ ├── a1\n" + + "│ └── a2\n" + + "└── b\n" + assert.Equal(t, expected, tree.String()) + }) + + t.Run("AddTree composes subtrees", func(t *testing.T) { + sub := NewTree("sub-root") + sub.Add("child") + + tree := NewTree("main") + tree.AddTree(sub) + + expected := "main\n" + + "└── sub-root\n" + + " └── child\n" + assert.Equal(t, expected, tree.String()) + }) + + t.Run("styled nodes", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + tree := NewTree("root") + tree.AddStyled("green", SuccessStyle) + tree.Add("plain") + + expected := "root\n" + + "├── green\n" + + "└── plain\n" + assert.Equal(t, expected, tree.String()) + }) + + t.Run("WithStyle on root", func(t *testing.T) { + SetColorEnabled(false) + defer SetColorEnabled(true) + + tree := NewTree("root").WithStyle(ErrorStyle) + tree.Add("child") + + expected := "root\n" + + "└── child\n" + assert.Equal(t, expected, tree.String()) + }) +} + +func TestTree_Bad(t *testing.T) { + t.Run("empty label", func(t *testing.T) { + tree := NewTree("") + assert.Equal(t, "\n", tree.String()) + }) +} diff --git a/pkg/cli/utils.go b/pkg/cli/utils.go new file mode 100644 index 00000000..ed012d2d --- /dev/null +++ b/pkg/cli/utils.go @@ -0,0 +1,505 @@ +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 +}