Skip to content

Conversation

@Arfey
Copy link
Contributor

@Arfey Arfey commented Jan 7, 2026

#60

Summary by Sourcery

Group CLI subcommands in help output by configurable group names and wire command configuration through to the root help display.

New Features:

  • Support grouping CLI commands in the root help output by group and subgroup names derived from command metadata.
  • Allow commands in the YAML configuration to specify a group_name for display in help output.

Tests:

  • Add Bats integration test to verify that lets --help shows commands grouped under their configured group names.
  • Add unit tests for parsing the command group_name field from YAML configuration.

Example

     A CLI task runner
   
     Usage:
       lets [flags]
       lets [command]
   
     Commands:
   
       Linters
         linter1     Runs first linter
         linter2     Runs second linter
   
       Common
         run         Run the application
   
       Test
         test        Runs all tests
   
     Internal commands:
       help        Help about any command
       self        Manage lets CLI itself
   
     Flags:
           --all                   show all commands (including the ones with _)
       -c, --config string         config file (default is lets.yaml)
       -d, --debug count           show debug logs (or use LETS_DEBUG=1). If used multiple times, shows more verbose logs
       -E, --env stringToString    set env variable for running command KEY=VALUE (default [])
           --exclude stringArray   run all but excluded command(s) described in cmd as map
       -h, --help                  help for lets
           --init                  create a new lets.yaml in the current folder
           --no-depends            skip 'depends' for running command
           --only stringArray      run only specified command(s) described in cmd as map
           --upgrade               upgrade lets to latest version
       -v, --version               version for lets
   
     Use "lets help [command]" for more information about a command.

Summary by Sourcery

Group CLI commands in the root help output by configurable group names derived from command configuration.

New Features:

  • Support assigning commands to named groups via a new group field in YAML configuration, defaulting to a Common group when unspecified.
  • Display root help output grouped by command group and subgroup names based on command metadata.

Documentation:

  • Document the new group configuration field and show examples of grouped commands in help output.

Tests:

  • Add integration test verifying that lets help groups commands by configured group names.
  • Add unit tests ensuring command group configuration is parsed correctly from YAML and defaults to the Common group.

@sourcery-ai
Copy link

sourcery-ai bot commented Jan 7, 2026

Reviewer's Guide

This PR introduces configurable command grouping in the root help output by adding a group field to command configuration, wiring it into Cobra command annotations, and implementing a custom root help renderer that groups and orders commands by group and subgroup names, along with tests and documentation updates.

Sequence diagram for grouped root help rendering

sequenceDiagram
  actor User
  participant Main
  participant RootFlags
  participant RootCommand
  participant RootHelp as PrintRootHelpMessage
  participant Cobra as CobraFramework

  User->>Main: invoke_lets_with_help_flag
  Main->>RootFlags: parse_flags
  RootFlags-->>Main: help_true
  Main->>RootCommand: initialize_root_command
  Main->>RootHelp: PrintRootHelpMessage(rootCmd)

  activate RootHelp
  RootHelp->>RootCommand: Short
  RootHelp->>RootCommand: UseLine()
  RootHelp->>RootCommand: CommandPath()

  loop for_each_group
    RootHelp->>RootCommand: Groups()
    RootHelp->>RootHelp: buildGroupCommandHelp(rootCmd, group)
    activate RootHelp
    RootHelp->>RootCommand: Commands()
    RootHelp->>RootCommand: NamePadding()
    RootHelp-->>RootHelp: group_and_subgroup_output
    deactivate RootHelp
  end

  RootHelp->>RootCommand: HasAvailableLocalFlags()
  alt has_flags
    RootHelp->>RootCommand: LocalFlags().FlagUsagesWrapped(120)
  end

  RootHelp->>RootCommand: CommandPath()
  RootHelp->>RootCommand: OutOrStdout()
  RootHelp-->>User: grouped_help_text
  deactivate RootHelp
Loading

Class diagram for updated command configuration and help wiring

classDiagram

class Command {
  +string Name
  +string GroupName
  +Cmds Cmds
  +UnmarshalYAML(unmarshal)
  +Clone()
  +GetEnv(cfg)
}

class CobraCommand {
  +string UseLine()
  +string CommandPath()
  +string Short
  +string Version
  +string Name()
  +string GroupID
  +string Title
  +map_string_string Annotations
  +bool Runnable()
  +bool HasAvailableSubCommands()
  +bool HasAvailableLocalFlags()
  +CobraCommand_array Commands()
  +CobraGroup_array Groups()
  +int NamePadding()
  +Flags LocalFlags()
  +Writer OutOrStdout()
}

class CobraGroup {
  +string ID
  +string Title
}

class RootHelp {
  +buildGroupCommandHelp(cmd, group) string
  +PrintRootHelpMessage(cmd) error
}

class SubcommandFactory {
  +newSubcommand(command, conf, showAll, out) CobraCommand
}

Command --> SubcommandFactory : input_to
SubcommandFactory --> CobraCommand : create_subcommand
CobraCommand --> RootHelp : passed_into
RootHelp --> CobraGroup : iterate_groups
CobraCommand --> CobraGroup : grouped_by_ID

Command --> Command : Clone
Loading

File-Level Changes

Change Details Files
Add configurable command group name to command configuration and propagation to subcommands.
  • Extend the Command struct with a GroupName field and default it to "Common" when not provided in YAML.
  • Update YAML unmarshalling to read the new group key into GroupName.
  • Ensure Command.Clone copies GroupName.
  • When constructing subcommands, attach the command's GroupName as a SubGroupName annotation on the Cobra command.
config/config/command.go
cmd/subcommand.go
Implement grouped root help output based on Cobra groups and subgroup annotations.
  • Introduce buildGroupCommandHelp to select commands belonging to a Cobra group, sort them, bucket them by subgroup name from annotations, and render aligned help sections.
  • Add PrintRootHelpMessage to assemble the overall help text: short description, usage, grouped commands (via buildGroupCommandHelp), flags and usage footer.
  • Switch main to use PrintRootHelpMessage instead of the previous PrintHelpMessage when showing root help.
cmd/root.go
main.go
Document the new group key and add tests for command grouping behavior.
  • Add group to the configuration reference with examples of grouping commands in help output.
  • Add a Bats integration test that runs lets help against a sample config and asserts the full grouped help text.
  • Add a unit test to verify default and custom GroupName parsing from YAML.
  • Add a test fixture lets.yaml defining commands in different groups to drive the integration test.
docs/docs/config.md
tests/command_group.bats
config/config/command_test.go
tests/command_group/lets.yaml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • The new buildGroupedCommandHelp function iterates over maps (groupCmdMap and the inner GroupsMap) which leads to non-deterministic ordering; if the help output or tests depend on a stable order, consider sorting groups/subgroups/commands instead of ranging over maps directly.
  • In PrintRootHelpMessage, you no longer prepend a "Commands:\n\n" section header before the grouped command list, but the new Bats fixture expects that header, so either reintroduce it or update the expected help text for consistency.
  • The UnmarshalYAML defaulting logic sets GroupName to "Common" when group_name is omitted, but TestParseCommand currently expects an empty string for the default; align the test expectation or the defaulting behavior so they match.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `buildGroupedCommandHelp` function iterates over maps (`groupCmdMap` and the inner `GroupsMap`) which leads to non-deterministic ordering; if the help output or tests depend on a stable order, consider sorting groups/subgroups/commands instead of ranging over maps directly.
- In `PrintRootHelpMessage`, you no longer prepend a `"Commands:\n\n"` section header before the grouped command list, but the new Bats fixture expects that header, so either reintroduce it or update the expected help text for consistency.
- The `UnmarshalYAML` defaulting logic sets `GroupName` to "Common" when `group_name` is omitted, but `TestParseCommand` currently expects an empty string for the default; align the test expectation or the defaulting behavior so they match.

## Individual Comments

### Comment 1
<location> `cmd/root.go:61-68` </location>
<code_context>
 	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 {
</code_context>

<issue_to_address>
**issue (bug_risk):** Iteration over maps will produce non-deterministic group and command ordering.

Because both `groupCmdMap` and its nested maps are iterated with `for ... range`, the help output order will vary between runs, which can cause flaky tests and noisy doc diffs. Since there’s already a TODO for sorting, consider using the existing `groups := cmd.Groups()` slice and sorting commands within each group (e.g., by `Use` or `Name()`) to ensure stable output.
</issue_to_address>

### Comment 2
<location> `cmd/subcommand.go:205-206` </location>
<code_context>
 			c.Println(err)
 		}
 	})
+	subCmd.Annotations = map[string]string{
+		"SubGroupName": command.GroupName,
+	}

</code_context>

<issue_to_address>
**issue (bug_risk):** Assigning a new Annotations map may clobber existing annotations on the command.

This overwrites any existing `subCmd.Annotations` set elsewhere (now or in future), which can lead to unexpected behavior. Instead, only initialize when nil and then set the key, e.g.:

```go
if subCmd.Annotations == nil {
    subCmd.Annotations = map[string]string{}
}
subCmd.Annotations["SubGroupName"] = command.GroupName
```
</issue_to_address>

### Comment 3
<location> `config/config/command_test.go:22-29` </location>
<code_context>
+}
+
+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)
+		}
</code_context>

<issue_to_address>
**issue (testing):** The "default group_name" test expectation contradicts the implementation default of "Common" and should be updated.

In `UnmarshalYAML`, `GroupName` defaults to `"Common"` when `group_name` is omitted, but this test expects an empty string. Please update the expected value to `"Common"` (and the assertion message if needed) so the test matches the actual default behavior.
</issue_to_address>

### Comment 4
<location> `tests/command_group_name.bats:9-18` </location>
<code_context>
+HELP_MESSAGE=$(cat <<EOF
</code_context>

<issue_to_address>
**issue (testing):** The help output assertion is brittle because the implementation uses Go maps without sorting, so order is not guaranteed.

The expected `HELP_MESSAGE` depends on a fixed ordering of groups and commands, but `buildGroupedCommandHelp` uses nested Go maps (`map[string]map[string][]*cobra.Command`) without sorting (see `// todo: add sort`), so output order is randomized and the test becomes flaky.

To fix this, either:
- Sort group names, subgroup names, and commands before formatting so a full-string assertion is valid, or
- Adjust the test to assert on key substrings/structure (e.g., via `assert_line` for each group/command and indentation) without relying on relative order.

The test should avoid depending on Go map iteration order.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +205 to +206
subCmd.Annotations = map[string]string{
"SubGroupName": command.GroupName,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Assigning a new Annotations map may clobber existing annotations on the command.

This overwrites any existing subCmd.Annotations set elsewhere (now or in future), which can lead to unexpected behavior. Instead, only initialize when nil and then set the key, e.g.:

if subCmd.Annotations == nil {
    subCmd.Annotations = map[string]string{}
}
subCmd.Annotations["SubGroupName"] = command.GroupName

@Arfey Arfey marked this pull request as draft January 7, 2026 11:28
}


func PrintRootHelpMessage(cmd *cobra.Command) error {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like if lets executed as is (without help command) there will be no groups in the output

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets/main.go

Line 111 in 3390a5d

showUsage := rootFlags.help || (command.Name() == "help" && len(args) == 0) || (len(os.Args) == 1)

fixed with (len(os.Args) == 1) and added tests

@test "help: running 'lets help' should group commands by their group names" {
run lets help
assert_success
assert_output "$HELP_MESSAGE"
}
@test "help: running 'lets --help' should group commands by their group names" {
run lets --help
assert_success
assert_output "$HELP_MESSAGE"
}
@test "help: running 'lets' should group commands by their group names" {
run lets
assert_success
assert_output "$HELP_MESSAGE"
}

@kindermax
Copy link
Collaborator

@Arfey overall looks good, lets fix remaining issues and its ready for merge

@Arfey Arfey marked this pull request as ready for review January 7, 2026 16:46
Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The hard-coded strings like "Common" for the default group name and "SubGroupName" for the annotation key are used in multiple places; consider extracting them into shared constants to avoid typos and make future changes safer.
  • In buildGroupCommandHelp, commands without a SubGroupName are handled in a separate loop after subgroup rendering; you may want to guard against accidentally printing a command twice if annotations are manipulated elsewhere by tightening the selection logic or documenting the assumption.
  • The naming of the SubGroupName annotation is a bit confusing given the YAML key is group and the docs talk about groups; consider aligning this terminology (e.g., GroupName everywhere) to make it clearer how grouping is configured and rendered.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The hard-coded strings like `"Common"` for the default group name and `"SubGroupName"` for the annotation key are used in multiple places; consider extracting them into shared constants to avoid typos and make future changes safer.
- In `buildGroupCommandHelp`, commands without a `SubGroupName` are handled in a separate loop after subgroup rendering; you may want to guard against accidentally printing a command twice if annotations are manipulated elsewhere by tightening the selection logic or documenting the assumption.
- The naming of the `SubGroupName` annotation is a bit confusing given the YAML key is `group` and the docs talk about groups; consider aligning this terminology (e.g., `GroupName` everywhere) to make it clearer how grouping is configured and rendered.

## Individual Comments

### Comment 1
<location> `cmd/subcommand.go:205-207` </location>
<code_context>
 			c.Println(err)
 		}
 	})
+	subCmd.Annotations = map[string]string{
+		"SubGroupName": command.GroupName,
+	}

</code_context>

<issue_to_address>
**suggestion (bug_risk):** Overwriting `subCmd.Annotations` may discard existing annotations.

If `subCmd` may already have annotations (now or later), this assignment will discard them. Instead, ensure `subCmd.Annotations` is initialized if nil, then set `subCmd.Annotations["SubGroupName"] = command.GroupName` so existing entries are preserved.

```suggestion
	if subCmd.Annotations == nil {
		subCmd.Annotations = make(map[string]string)
	}
	subCmd.Annotations["SubGroupName"] = command.GroupName
```
</issue_to_address>

### Comment 2
<location> `config/config/command_test.go:5` </location>
<code_context>
+
+import (
+	"bytes"
+	"testing"
+
+	"github.com/lithammer/dedent"
</code_context>

<issue_to_address>
**suggestion (testing):** Add a test case for an explicitly empty `group:` field defaulting to "Common"

The unmarshal logic treats `group: ""` the same as a missing `group` (both default to "Common"). Existing tests cover the missing-group case and a custom group, but not the explicitly empty string. Please add a subtest for `group: ""` to capture this behavior and prevent regressions in the defaulting logic.

Suggested implementation:

```golang
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 TestCommandUnmarshal_EmptyGroupDefaultsToCommon(t *testing.T) {
	yamlText := dedent.Dedent(`
		name: example
		command: echo "hello"
		group: ""
	`)

	cmd := CommandFixture(t, yamlText)

	if cmd.Group != "Common" {
		t.Fatalf("expected Group to default to %q for empty group field, got %q", "Common", cmd.Group)
	}
}


```

If `CommandFixture` already returns `c` in the current file (the snippet may be truncated), you should:
1. Remove the duplicate `return c` introduced by this edit and keep only a single `return c` before the helper’s closing brace.
2. Optionally, if you want this to be a subtest under an existing test (e.g., `TestCommandUnmarshal`), you can move the body of `TestCommandUnmarshal_EmptyGroupDefaultsToCommon` into a `t.Run("empty group defaults to Common", func(t *testing.T) { ... })` inside that existing test instead of having a standalone test function.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +205 to +207
subCmd.Annotations = map[string]string{
"SubGroupName": command.GroupName,
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Overwriting subCmd.Annotations may discard existing annotations.

If subCmd may already have annotations (now or later), this assignment will discard them. Instead, ensure subCmd.Annotations is initialized if nil, then set subCmd.Annotations["SubGroupName"] = command.GroupName so existing entries are preserved.

Suggested change
subCmd.Annotations = map[string]string{
"SubGroupName": command.GroupName,
}
if subCmd.Annotations == nil {
subCmd.Annotations = make(map[string]string)
}
subCmd.Annotations["SubGroupName"] = command.GroupName

@kindermax kindermax merged commit 4808be9 into lets-cli:master Jan 8, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants