diff --git a/cmd/root.go b/cmd/root.go index 22c383571..d02cacf5d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/pflag" e "github.com/cloudposse/atmos/internal/exec" + "github.com/cloudposse/atmos/internal/tui/templates" tuiUtils "github.com/cloudposse/atmos/internal/tui/utils" cfg "github.com/cloudposse/atmos/pkg/config" "github.com/cloudposse/atmos/pkg/schema" @@ -107,12 +108,17 @@ func Execute() error { } func init() { + // Add template function for wrapped flag usages + cobra.AddTemplateFunc("wrappedFlagUsages", templates.WrappedFlagUsages) + RootCmd.PersistentFlags().String("redirect-stderr", "", "File descriptor to redirect 'stderr' to. "+ "Errors can be redirected to any file or any standard file descriptor (including '/dev/null'): atmos --redirect-stderr /dev/stdout") RootCmd.PersistentFlags().String("logs-level", "Info", "Logs level. Supported log levels are Trace, Debug, Info, Warning, Off. If the log level is set to Off, Atmos will not log any messages") RootCmd.PersistentFlags().String("logs-file", "/dev/stdout", "The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including '/dev/stdout', '/dev/stderr' and '/dev/null'") + // Set custom usage template + templates.SetCustomUsageFunc(RootCmd) cobra.OnInitialize(initConfig) } @@ -131,6 +137,7 @@ func initConfig() { } b.HelpFunc(command, strings) + command.Usage() }) } diff --git a/go.mod b/go.mod index 445b0e8a0..a0ba87eee 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/kubescape/go-git-url v0.0.30 github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e github.com/mitchellh/go-homedir v1.1.0 + github.com/mitchellh/go-wordwrap v1.0.0 github.com/mitchellh/mapstructure v1.5.0 github.com/open-policy-agent/opa v0.70.0 github.com/otiai10/copy v1.14.0 @@ -41,6 +42,7 @@ require ( github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 github.com/zclconf/go-cty v1.15.0 + golang.org/x/term v0.26.0 gopkg.in/yaml.v3 v3.0.1 mvdan.cc/sh/v3 v3.10.0 ) @@ -174,7 +176,6 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect - github.com/mitchellh/go-wordwrap v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/locker v1.0.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -241,8 +242,7 @@ require ( golang.org/x/net v0.30.0 // indirect golang.org/x/oauth2 v0.22.0 // indirect golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/term v0.25.0 // indirect + golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.19.0 // indirect golang.org/x/time v0.7.0 // indirect golang.org/x/tools v0.22.0 // indirect diff --git a/go.sum b/go.sum index cc52dfecc..79a99a901 100644 --- a/go.sum +++ b/go.sum @@ -1533,6 +1533,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -1540,6 +1542,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= +golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/tui/templates/help_printer.go b/internal/tui/templates/help_printer.go new file mode 100644 index 000000000..4c78e0de8 --- /dev/null +++ b/internal/tui/templates/help_printer.go @@ -0,0 +1,122 @@ +package templates + +import ( + "fmt" + "io" + "strings" + + "github.com/mitchellh/go-wordwrap" + "github.com/spf13/pflag" +) + +const ( + defaultOffset = 10 + minWidth = 80 + flagIndent = " " + nameIndentWidth = 4 + minDescWidth = 20 +) + +type HelpFlagPrinter struct { + wrapLimit uint + out io.Writer + maxFlagLen int +} + +func NewHelpFlagPrinter(out io.Writer, wrapLimit uint, flags *pflag.FlagSet) *HelpFlagPrinter { + if out == nil { + panic("output writer cannot be nil") + } + if flags == nil { + panic("flag set cannot be nil") + } + if wrapLimit < minWidth { + wrapLimit = minWidth + } + + return &HelpFlagPrinter{ + wrapLimit: wrapLimit, + out: out, + maxFlagLen: calculateMaxFlagLength(flags), + } +} + +func calculateMaxFlagLength(flags *pflag.FlagSet) int { + maxLen := 0 + flags.VisitAll(func(flag *pflag.Flag) { + length := len(flagIndent) + + if len(flag.Shorthand) > 0 { + if flag.Value.Type() != "bool" { + length += len(fmt.Sprintf("-%s, --%s %s", flag.Shorthand, flag.Name, flag.Value.Type())) + } else { + length += len(fmt.Sprintf("-%s, --%s", flag.Shorthand, flag.Name)) + } + } else { + if flag.Value.Type() != "bool" { + length += len(fmt.Sprintf(" --%s %s", flag.Name, flag.Value.Type())) + } else { + length += len(fmt.Sprintf(" --%s", flag.Name)) + } + } + + if length > maxLen { + maxLen = length + } + }) + return maxLen +} + +func (p *HelpFlagPrinter) PrintHelpFlag(flag *pflag.Flag) { + nameIndent := nameIndentWidth + + flagName := "" + if flag.Shorthand != "" { + if flag.Value.Type() != "bool" { + flagName = fmt.Sprintf("%s-%s, --%s %s", strings.Repeat(" ", nameIndent), + flag.Shorthand, flag.Name, flag.Value.Type()) + } else { + flagName = fmt.Sprintf("%s-%s, --%s", strings.Repeat(" ", nameIndent), + flag.Shorthand, flag.Name) + } + } else { + if flag.Value.Type() != "bool" { + flagName = fmt.Sprintf("%s --%s %s", strings.Repeat(" ", nameIndent), + flag.Name, flag.Value.Type()) + } else { + flagName = fmt.Sprintf("%s --%s", strings.Repeat(" ", nameIndent), + flag.Name) + } + } + + flagSection := fmt.Sprintf("%-*s", p.maxFlagLen, flagName) + descIndent := p.maxFlagLen + 4 + + description := flag.Usage + if flag.DefValue != "" { + description = fmt.Sprintf("%s (default %q)", description, flag.DefValue) + } + + descWidth := int(p.wrapLimit) - descIndent + if descWidth < minDescWidth { + descWidth = minDescWidth + } + + wrapped := wordwrap.WrapString(description, uint(descWidth)) + lines := strings.Split(wrapped, "\n") + + if _, err := fmt.Fprintf(p.out, "%-*s%s\n", descIndent, flagSection, lines[0]); err != nil { + return + } + + // Print remaining lines with proper indentation + for _, line := range lines[1:] { + if _, err := fmt.Fprintf(p.out, "%s%s\n", strings.Repeat(" ", descIndent), line); err != nil { + return + } + } + + if _, err := fmt.Fprintln(p.out); err != nil { + return + } +} diff --git a/internal/tui/templates/templater.go b/internal/tui/templates/templater.go new file mode 100644 index 000000000..a9f3c2bea --- /dev/null +++ b/internal/tui/templates/templater.go @@ -0,0 +1,73 @@ +package templates + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Templater handles the generation and management of command usage templates. +type Templater struct { + UsageTemplate string +} + +// SetCustomUsageFunc configures a custom usage template for the provided cobra command. +// It returns an error if the command is nil. +func SetCustomUsageFunc(cmd *cobra.Command) error { + if cmd == nil { + return fmt.Errorf("command cannot be nil") + } + t := &Templater{ + UsageTemplate: MainUsageTemplate(), + } + + cmd.SetUsageTemplate(t.UsageTemplate) + return nil +} + +// MainUsageTemplate returns the usage template for the root command and wrap cobra flag usages to the terminal width +func MainUsageTemplate() string { + return `Usage:{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}} + +Aliases: + {{.NameAndAliases}}{{end}}{{if .HasExample}} + +Examples: +{{.Example}}{{end}}{{if .HasAvailableSubCommands}} + +Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} + +Flags: +{{wrappedFlagUsages .LocalFlags | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} + +Global Flags: +{{wrappedFlagUsages .InheritedFlags | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} + +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} +` +} + +// Default terminal width if actual width cannot be determined +const maxWidth = 80 + +// WrappedFlagUsages formats the flag usage string to fit within the terminal width +func WrappedFlagUsages(f *pflag.FlagSet) string { + var builder strings.Builder + printer := NewHelpFlagPrinter(&builder, maxWidth, f) + + printer.maxFlagLen = calculateMaxFlagLength(f) + + f.VisitAll(func(flag *pflag.Flag) { + printer.PrintHelpFlag(flag) + }) + + return builder.String() +} diff --git a/internal/tui/templates/term/term_writer.go b/internal/tui/templates/term/term_writer.go new file mode 100644 index 000000000..2af038112 --- /dev/null +++ b/internal/tui/templates/term/term_writer.go @@ -0,0 +1,79 @@ +package term + +import ( + "io" + "os" + + "github.com/mitchellh/go-wordwrap" + "golang.org/x/term" +) + +// TerminalWriter wraps an io.Writer and provides automatic line wrapping based on terminal width +// It ensures that output text is formatted to fit within the terminal's dimensions. +type TerminalWriter struct { + width uint + writer io.Writer +} + +const ( + maxWidth = 120 + mediumWidth = 100 + minWidth = 80 +) + +// NewResponsiveWriter creates a terminal-aware writer that automatically wraps text +// based on the terminal width. If the provided writer is not a terminal or if width +// detection fails, it will return the original writer unchanged. +func NewResponsiveWriter(w io.Writer) io.Writer { + file, ok := w.(*os.File) + if !ok { + return w + } + + if !term.IsTerminal(int(file.Fd())) { + return w + } + + width, _, err := term.GetSize(int(file.Fd())) + if err != nil { + return w + } + + // Use optimal width based on terminal size + var limit uint + switch { + case width >= maxWidth: + limit = maxWidth + case width >= mediumWidth: + limit = mediumWidth + case width >= minWidth: + limit = minWidth + default: + limit = uint(width) + } + + return &TerminalWriter{ + width: limit, + writer: w, + } +} + +func (w *TerminalWriter) Write(p []byte) (int, error) { + if w.width == 0 { + return w.writer.Write(p) + } + + // Preserving the original length for correct return value + originalLen := len(p) + wrapped := wordwrap.WrapString(string(p), w.width) + n, err := w.writer.Write([]byte(wrapped)) + if err != nil { + return n, err + } + // return the original length as per io.Writer contract + return originalLen, nil +} + +func (w *TerminalWriter) GetWidth() uint { + return w.width +}