From 628416a21dbbd23e60d0575d1c0bf27566cdaf6a Mon Sep 17 00:00:00 2001 From: Arfey Date: Wed, 7 Jan 2026 13:09:26 +0200 Subject: [PATCH 1/2] added subgroup name --- cmd/root.go | 70 ++++++++++++++++++++++++++++++ cmd/subcommand.go | 3 ++ config/config/command.go | 9 ++++ config/config/command_test.go | 46 ++++++++++++++++++++ main.go | 2 +- tests/command_group_name.bats | 51 ++++++++++++++++++++++ tests/command_group_name/lets.yaml | 22 ++++++++++ 7 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 config/config/command_test.go create mode 100644 tests/command_group_name.bats create mode 100644 tests/command_group_name/lets.yaml diff --git a/cmd/root.go b/cmd/root.go index 4b79f3b..402030b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -58,6 +58,76 @@ func PrintHelpMessage(cmd *cobra.Command) error { return err } +func buildGroupedCommandHelp(cmd *cobra.Command) string { + help := "" + cmds := cmd.Commands() + groups := cmd.Groups() + + groupCmdMap := make(map[string]map[string][]*cobra.Command) + + // todo: add sort + + for _, group := range groups { + if _, ok := groupCmdMap[group.Title]; !ok { + groupCmdMap[group.Title] = make(map[string][]*cobra.Command) + } + for _, c := range cmds { + if c.GroupID == group.ID && (c.IsAvailableCommand() || c.Name() == "help") { + subgroup := c.Annotations["SubGroupName"] + groupCmdMap[group.Title][subgroup] = append(groupCmdMap[group.Title][subgroup], c) + } + } + } + + for groupName, GroupsMap := range groupCmdMap { + help += fmt.Sprintf("%s\n", groupName) + for subgroupName, cmds := range GroupsMap { + intend := "" + if len(GroupsMap) > 1 { + help += fmt.Sprintf("\n %-*s\n", cmd.NamePadding(), subgroupName) + intend = " " + } + for _, c := range cmds { + help += fmt.Sprintf("%s %-*s %s\n", intend, cmd.NamePadding(), c.Name(), c.Short) + } + } + help += "\n" + } + return help +} + + +func PrintRootHelpMessage(cmd *cobra.Command) error { + help := "" + help = fmt.Sprintf("%s\n\n%s", cmd.Short, help) + + // General + help += "Usage:\n" + if cmd.Runnable() { + help += fmt.Sprintf(" %s\n", cmd.UseLine()) + } + if cmd.HasAvailableSubCommands() { + help += fmt.Sprintf(" %s [command]\n", cmd.CommandPath()) + } + help += "\n" + + // Commands + help += buildGroupedCommandHelp(cmd) + + // Flags + if cmd.HasAvailableLocalFlags() { + help += "Flags:\n" + help += cmd.LocalFlags().FlagUsagesWrapped(120) + help += "\n" + } + + // Usage + help += fmt.Sprintf(`Use "%s help [command]" for more information about a command.`, cmd.CommandPath()) + + _, err := fmt.Fprint(cmd.OutOrStdout(), help) + return err +} + func PrintVersionMessage(cmd *cobra.Command) error { _, err := fmt.Fprintf(cmd.OutOrStdout(), "lets version %s\n", cmd.Version) return err diff --git a/cmd/subcommand.go b/cmd/subcommand.go index df2f87e..759c03f 100644 --- a/cmd/subcommand.go +++ b/cmd/subcommand.go @@ -202,6 +202,9 @@ func newSubcommand(command *config.Command, conf *config.Config, showAll bool, o c.Println(err) } }) + subCmd.Annotations = map[string]string{ + "SubGroupName": command.GroupName, + } return subCmd } diff --git a/config/config/command.go b/config/config/command.go index 148ac0b..3965843 100644 --- a/config/config/command.go +++ b/config/config/command.go @@ -14,6 +14,7 @@ import ( type Command struct { Name string + GroupName string // Represents a list of commands (scripts) Cmds Cmds // script to run after cmd finished (cleanup, etc) @@ -57,6 +58,7 @@ func (c *Command) UnmarshalYAML(unmarshal func(interface{}) error) error { } var cmd struct { + GroupName string `yaml:"group_name"` Cmd Cmds Description string Shell string @@ -75,6 +77,12 @@ func (c *Command) UnmarshalYAML(unmarshal func(interface{}) error) error { return err } + if cmd.GroupName != "" { + c.GroupName = cmd.GroupName + } else { + c.GroupName = "Common" + } + c.Cmds = cmd.Cmd c.Description = cmd.Description c.Env = cmd.Env @@ -141,6 +149,7 @@ func (c *Command) GetEnv(cfg Config) (map[string]string, error) { func (c *Command) Clone() *Command { cmd := &Command{ Name: c.Name, + GroupName: c.GroupName, Cmds: c.Cmds.Clone(), After: c.After, Shell: c.Shell, diff --git a/config/config/command_test.go b/config/config/command_test.go new file mode 100644 index 0000000..efdb328 --- /dev/null +++ b/config/config/command_test.go @@ -0,0 +1,46 @@ +package config + +import ( + "bytes" + "testing" + + "github.com/lithammer/dedent" + "gopkg.in/yaml.v3" +) + +func CommandFixture(t *testing.T, text string) *Command { + buf := bytes.NewBufferString(text) + c := &Command{} + if err := yaml.NewDecoder(buf).Decode(&c); err != nil { + t.Fatalf("command fixture decode error: %s", err) + } + + return c +} + +func TestParseCommand(t *testing.T) { + t.Run("default group_name", func(t *testing.T) { + text := dedent.Dedent(` + cmd: [echo, Hello] + `) + command := CommandFixture(t, text) + exp := "" + + if command.GroupName != exp { + t.Errorf("wrong output. \nexpect %s \ngot: %s", exp, command.GroupName) + } + }) + + t.Run("provided custom group_name", func(t *testing.T) { + text := dedent.Dedent(` + group_name: Group Name + cmd: [echo, Hello] + `) + command := CommandFixture(t, text) + exp := "Group Name" + + if command.GroupName != exp { + t.Errorf("wrong output. \nexpect %s \ngot: %s", exp, command.GroupName) + } + }) +} diff --git a/main.go b/main.go index d8db930..51cda6b 100644 --- a/main.go +++ b/main.go @@ -111,7 +111,7 @@ func main() { showUsage := rootFlags.help || (command.Name() == "help" && len(args) == 0) if showUsage { - if err := cmd.PrintHelpMessage(rootCmd); err != nil { + if err := cmd.PrintRootHelpMessage(rootCmd); err != nil { log.Errorf("lets: print help error: %s", err) os.Exit(1) } diff --git a/tests/command_group_name.bats b/tests/command_group_name.bats new file mode 100644 index 0000000..168e1fd --- /dev/null +++ b/tests/command_group_name.bats @@ -0,0 +1,51 @@ +load test_helpers + +setup() { + load "${BATS_UTILS_PATH}/bats-support/load.bash" + load "${BATS_UTILS_PATH}/bats-assert/load.bash" + cd ./tests/command_group_name +} + +HELP_MESSAGE=$(cat < Date: Wed, 7 Jan 2026 18:45:46 +0200 Subject: [PATCH 2/2] fixes after review --- cmd/root.go | 92 +++++++++++-------- config/config/command.go | 2 +- config/config/command_test.go | 8 +- docs/docs/config.md | 43 ++++++++- main.go | 2 +- ...and_group_name.bats => command_group.bats} | 34 +++++-- tests/command_group/lets.yaml | 21 +++++ tests/command_group_name/lets.yaml | 22 ----- 8 files changed, 151 insertions(+), 73 deletions(-) rename tests/{command_group_name.bats => command_group.bats} (70%) create mode 100644 tests/command_group/lets.yaml delete mode 100644 tests/command_group_name/lets.yaml diff --git a/cmd/root.go b/cmd/root.go index 402030b..3226173 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,8 +3,10 @@ package cmd import ( "fmt" "strings" + "sort" "github.com/spf13/cobra" + "github.com/lets-cli/lets/set" ) // newRootCmd represents the base command when called without any subcommands. @@ -58,42 +60,58 @@ func PrintHelpMessage(cmd *cobra.Command) error { return err } -func buildGroupedCommandHelp(cmd *cobra.Command) string { - help := "" - cmds := cmd.Commands() - groups := cmd.Groups() - - groupCmdMap := make(map[string]map[string][]*cobra.Command) - - // todo: add sort - - for _, group := range groups { - if _, ok := groupCmdMap[group.Title]; !ok { - groupCmdMap[group.Title] = make(map[string][]*cobra.Command) - } - for _, c := range cmds { - if c.GroupID == group.ID && (c.IsAvailableCommand() || c.Name() == "help") { - subgroup := c.Annotations["SubGroupName"] - groupCmdMap[group.Title][subgroup] = append(groupCmdMap[group.Title][subgroup], c) - } - } - } - - for groupName, GroupsMap := range groupCmdMap { - help += fmt.Sprintf("%s\n", groupName) - for subgroupName, cmds := range GroupsMap { - intend := "" - if len(GroupsMap) > 1 { - help += fmt.Sprintf("\n %-*s\n", cmd.NamePadding(), subgroupName) - intend = " " - } - for _, c := range cmds { +func buildGroupCommandHelp(cmd *cobra.Command, group *cobra.Group) string { + help := "" + cmds := []*cobra.Command{} + + // select commands that belong to the specified group + for _, c := range cmd.Commands() { + if c.GroupID == group.ID && (c.IsAvailableCommand() || c.Name() == "help") { + cmds = append(cmds, c) + } + } + + sort.Slice(cmds, func(i, j int) bool { + return cmds[i].Name() < cmds[j].Name() + }) + + // Create a list of subgroups + subGroupNameSet := set.NewSet[string]() + + for _, c := range cmds { + if subgroup, ok := c.Annotations["SubGroupName"]; ok && subgroup != "" { + subGroupNameSet.Add(subgroup) + } + } + + subGroupNameList := subGroupNameSet.ToList() + sort.Strings(subGroupNameList) + + // generate output + help += fmt.Sprintf("%s\n", group.Title) + + for _, subgroupName := range subGroupNameList { + intend := "" + if len(subGroupNameList) > 1 { + help += fmt.Sprintf("\n %s\n", subgroupName) + intend = " " + } + for _, c := range cmds { + if subgroup, ok := c.Annotations["SubGroupName"]; ok && subgroup == subgroupName { help += fmt.Sprintf("%s %-*s %s\n", intend, cmd.NamePadding(), c.Name(), c.Short) - } - } - help += "\n" - } - return help + } + } + } + + for _, c := range cmds { + if _, ok := c.Annotations["SubGroupName"]; !ok { + help += fmt.Sprintf(" %-*s %s\n", cmd.NamePadding(), c.Name(), c.Short) + } + } + + help += "\n" + + return help } @@ -112,7 +130,9 @@ func PrintRootHelpMessage(cmd *cobra.Command) error { help += "\n" // Commands - help += buildGroupedCommandHelp(cmd) + for _, group := range cmd.Groups() { + help += buildGroupCommandHelp(cmd, group) + } // Flags if cmd.HasAvailableLocalFlags() { diff --git a/config/config/command.go b/config/config/command.go index 3965843..68af75b 100644 --- a/config/config/command.go +++ b/config/config/command.go @@ -58,7 +58,7 @@ func (c *Command) UnmarshalYAML(unmarshal func(interface{}) error) error { } var cmd struct { - GroupName string `yaml:"group_name"` + GroupName string `yaml:"group"` Cmd Cmds Description string Shell string diff --git a/config/config/command_test.go b/config/config/command_test.go index efdb328..f02865e 100644 --- a/config/config/command_test.go +++ b/config/config/command_test.go @@ -19,21 +19,21 @@ func CommandFixture(t *testing.T, text string) *Command { } func TestParseCommand(t *testing.T) { - t.Run("default group_name", func(t *testing.T) { + t.Run("default group", func(t *testing.T) { text := dedent.Dedent(` cmd: [echo, Hello] `) command := CommandFixture(t, text) - exp := "" + exp := "Common" if command.GroupName != exp { t.Errorf("wrong output. \nexpect %s \ngot: %s", exp, command.GroupName) } }) - t.Run("provided custom group_name", func(t *testing.T) { + t.Run("provided custom group", func(t *testing.T) { text := dedent.Dedent(` - group_name: Group Name + group: Group Name cmd: [echo, Hello] `) command := CommandFixture(t, text) diff --git a/docs/docs/config.md b/docs/docs/config.md index 517b940..1411343 100644 --- a/docs/docs/config.md +++ b/docs/docs/config.md @@ -31,6 +31,7 @@ title: Config reference - [`persist_checksum`](#persist_checksum) - [`ref`](#ref) - [`args`](#args) + - [`group`](#group) - [Aliasing:](#aliasing) - [Env aliasing](#env-aliasing) @@ -193,7 +194,7 @@ Bar #### Conditional init If you need to make sure that code in `init` is called once with some condition, -you can for example create a file at the end of `init` script and check if this +you can for example create a file at the end of `init` script and check if this file exists at the beginning of `init` script. Example: @@ -871,6 +872,46 @@ commands: `args` is used only with [ref](#ref) and allows to set additional positional args to referenced command. See [ref](#ref) example. +### `group` + +`key: group` + +`type: string` + +Commands can be organized into groups for better readability in the help output. To assign a command to a group, use the `group` key: + +```yaml +commands: + build: + group: Build & Deploy + description: Build the project + cmd: npm run build + + deploy: + group: Build & Deploy + description: Deploy the project + cmd: npm run deploy + + test: + group: Testing + description: Run tests + cmd: npm test +``` + +When you run `lets help`, commands will be listed under their respective groups, making it easier to find related commands. + +``` +Commands: + + Build & Deploy + build Build the project + deploy Deploy the project + + Testing + test Run tests +``` + + ## Aliasing: Lets supports YAML aliasing in various places in the config diff --git a/main.go b/main.go index 51cda6b..f93c60d 100644 --- a/main.go +++ b/main.go @@ -108,7 +108,7 @@ func main() { os.Exit(0) } - showUsage := rootFlags.help || (command.Name() == "help" && len(args) == 0) + showUsage := rootFlags.help || (command.Name() == "help" && len(args) == 0) || (len(os.Args) == 1) if showUsage { if err := cmd.PrintRootHelpMessage(rootCmd); err != nil { diff --git a/tests/command_group_name.bats b/tests/command_group.bats similarity index 70% rename from tests/command_group_name.bats rename to tests/command_group.bats index 168e1fd..3bde50a 100644 --- a/tests/command_group_name.bats +++ b/tests/command_group.bats @@ -3,7 +3,7 @@ load test_helpers setup() { load "${BATS_UTILS_PATH}/bats-support/load.bash" load "${BATS_UTILS_PATH}/bats-assert/load.bash" - cd ./tests/command_group_name + cd ./tests/command_group } HELP_MESSAGE=$(cat <