Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 41 additions & 8 deletions dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ type RequiredFeatureProvider interface {
RequiredFeature() string
}

// CommandGroupProvider is an interface for commands that belong to a named group.
// Groups are used by consumers to organize commands in help output (e.g. separating
// operator commands from deployer commands).
type CommandGroupProvider interface {
// CommandGroup returns the group name for this command, or "" for ungrouped.
CommandGroup() string
}

// OutputFormatter is an interface for commands that can specify their output format
type OutputFormatter interface {
// OutputFormat returns the output format for this command
Expand All @@ -91,6 +99,7 @@ type funcCommand struct {
usage string
examples []Example
outputFormat OutputFormat
group string
}

// CommandOption is a functional option for configuring a command
Expand All @@ -110,6 +119,13 @@ func WithExamples(examples ...Example) CommandOption {
}
}

// WithCommandGroup sets the group name for the command.
func WithCommandGroup(group string) CommandOption {
return func(c *funcCommand) {
c.group = group
}
}

// WithOutputFormat sets the output format for the command
func WithOutputFormat(format OutputFormat) CommandOption {
return func(c *funcCommand) {
Expand Down Expand Up @@ -157,6 +173,11 @@ func (c *funcCommand) Examples() []Example {
return c.examples
}

// CommandGroup returns the group name for this command.
func (c *funcCommand) CommandGroup() string {
return c.group
}

// OutputFormat returns the output format for this command
func (c *funcCommand) OutputFormat() OutputFormat {
return c.outputFormat
Expand All @@ -180,6 +201,11 @@ type Dispatcher struct {
name string
}

// Name returns the dispatcher's program name.
func (d *Dispatcher) Name() string {
return d.name
}

// NewDispatcher creates a new command dispatcher
func NewDispatcher(name string) *Dispatcher {
return &Dispatcher{
Expand Down Expand Up @@ -504,7 +530,7 @@ func (d *Dispatcher) showHelp() error {
fmt.Printf("Usage: %s <command> [arguments]\n\n", d.name)
fmt.Println("Available commands:")

children := d.getDirectChildren("")
children := d.GetDirectChildren("")

// Find max length for alignment
maxLen := 0
Expand All @@ -515,7 +541,7 @@ func (d *Dispatcher) showHelp() error {
}

for _, child := range children {
grandchildren := d.getDirectChildren(child.Path)
grandchildren := d.GetDirectChildren(child.Path)
suffix := ""
if len(grandchildren) > 0 {
suffix = " " + faint(subCommandsLabel(len(grandchildren)))
Expand Down Expand Up @@ -599,7 +625,7 @@ func (d *Dispatcher) showCommandHelp(entry *CommandEntry) error {
}

// Show sub-commands if any exist (including implicit namespaces)
children := d.getDirectChildren(entry.Path)
children := d.GetDirectChildren(entry.Path)
if len(children) > 0 {
fmt.Println("\nSub-commands:")

Expand All @@ -613,7 +639,7 @@ func (d *Dispatcher) showCommandHelp(entry *CommandEntry) error {

// Print sub-commands with usage
for _, child := range children {
grandchildren := d.getDirectChildren(child.Path)
grandchildren := d.GetDirectChildren(child.Path)
suffix := ""
if len(grandchildren) > 0 {
suffix = " " + faint(subCommandsLabel(len(grandchildren)))
Expand Down Expand Up @@ -662,12 +688,14 @@ type ChildEntry struct {
Path string // The full path (parentPath + " " + Name, or just Name for top-level)
Usage string // Usage text (from registered command, or empty for namespaces)
IsEntry bool // True if this is a registered command, false if just a namespace
Group string // Group name from CommandGroupProvider, or empty for ungrouped
}

// getDirectChildren returns the direct children of a path, including both
// GetDirectChildren returns the direct children of a path, including both
// registered commands and implicit namespaces. If parentPath is empty, returns
// top-level entries.
func (d *Dispatcher) getDirectChildren(parentPath string) []ChildEntry {
func (d *Dispatcher) GetDirectChildren(parentPath string) []ChildEntry {
parentPath = normalizeCommandPath(parentPath)
children := make(map[string]*ChildEntry)

for path, entry := range d.commands {
Expand Down Expand Up @@ -695,11 +723,16 @@ func (d *Dispatcher) getDirectChildren(parentPath string) []ChildEntry {

if len(parts) == 1 {
// Direct child command
group := ""
if gp, ok := entry.Command.(CommandGroupProvider); ok {
group = gp.CommandGroup()
}
children[childName] = &ChildEntry{
Name: childName,
Path: childPath,
Usage: entry.Usage,
IsEntry: true,
Group: group,
}
} else {
// Deeper command — childName is a namespace (unless already registered)
Expand Down Expand Up @@ -743,7 +776,7 @@ func (d *Dispatcher) isNamespace(path string) bool {
func (d *Dispatcher) showNamespaceHelp(namespacePath string) error {
fmt.Printf("Usage: %s %s <command> [arguments]\n", d.name, namespacePath)

children := d.getDirectChildren(namespacePath)
children := d.GetDirectChildren(namespacePath)

if len(children) > 0 {
fmt.Println("\nAvailable commands:")
Expand All @@ -756,7 +789,7 @@ func (d *Dispatcher) showNamespaceHelp(namespacePath string) error {
}

for _, child := range children {
grandchildren := d.getDirectChildren(child.Path)
grandchildren := d.GetDirectChildren(child.Path)
suffix := ""
if len(grandchildren) > 0 {
suffix = " " + faint(subCommandsLabel(len(grandchildren)))
Expand Down
75 changes: 75 additions & 0 deletions dispatcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1699,3 +1699,78 @@ func TestDispatcherErrShowHelp(t *testing.T) {
})
}

func TestGetDirectChildrenPopulatesGroup(t *testing.T) {
d := NewDispatcher("myapp")

d.Dispatch("deploy", NewCommand(NewFlagSet("deploy"), nil, WithUsage("Deploy an app")))
d.Dispatch("server", NewCommand(NewFlagSet("server"), nil, WithUsage("Start server"), WithCommandGroup("Operator")))
d.Dispatch("debug", NewCommand(NewFlagSet("debug"), nil, WithUsage("Debug tools"), WithCommandGroup("Operator")))

children := d.GetDirectChildren("")

groups := make(map[string]string)
for _, child := range children {
groups[child.Name] = child.Group
}

assert.Contains(t, groups, "deploy")
assert.Equal(t, "", groups["deploy"])
assert.Contains(t, groups, "server")
assert.Equal(t, "Operator", groups["server"])
assert.Contains(t, groups, "debug")
assert.Equal(t, "Operator", groups["debug"])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func TestGetDirectChildrenGroupForNamespaceOverriddenByCommand(t *testing.T) {
d := NewDispatcher("myapp")

// Register a subcommand first (creates implicit namespace for "server")
d.Dispatch("server config", NewCommand(NewFlagSet("server config"), nil, WithUsage("Server config")))
// Then register the parent command with a group
d.Dispatch("server", NewCommand(NewFlagSet("server"), nil, WithUsage("Start server"), WithCommandGroup("Operator")))

children := d.GetDirectChildren("")

var serverEntry ChildEntry
for _, child := range children {
if child.Name == "server" {
serverEntry = child
break
}
}

assert.True(t, serverEntry.IsEntry)
assert.Equal(t, "Operator", serverEntry.Group)
}

func TestHelpDocIncludesGroup(t *testing.T) {
d := NewDispatcher("myapp")

d.Dispatch("deploy", NewCommand(NewFlagSet("deploy"), nil, WithUsage("Deploy an app")))
d.Dispatch("server", NewCommand(NewFlagSet("server"), nil, WithUsage("Start server"), WithCommandGroup("Operator")))

doc := d.HelpDoc()

groups := make(map[string]string)
for _, cmd := range doc.Commands {
groups[cmd.Path] = cmd.Group
}

assert.Contains(t, groups, "deploy")
assert.Equal(t, "", groups["deploy"])
assert.Contains(t, groups, "server")
assert.Equal(t, "Operator", groups["server"])
}

func TestHelpJSONIncludesGroup(t *testing.T) {
d := NewDispatcher("myapp")

d.Dispatch("server", NewCommand(NewFlagSet("server"), nil, WithUsage("Start server"), WithCommandGroup("Operator")))

data, err := d.HelpJSON()
assert.NoError(t, err)

json := string(data)
assert.Contains(t, json, `"group": "Operator"`)
Comment thread
phinze marked this conversation as resolved.
}

13 changes: 13 additions & 0 deletions fromstruct.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ var knownTags = map[string]bool{
"default": true,
"env": true,
"required": true,
"hidden": true,
"usage": true,
"description": true,
"choice": true,
Expand All @@ -70,6 +71,16 @@ var knownTags = map[string]bool{
"group": true,
}

// isHiddenTag reports whether a hidden:"..." struct tag value is truthy.
// Accepts the common boolean-ish forms callers tend to emit.
func isHiddenTag(v string) bool {
switch v {
case "yes", "true", "1":
return true
}
return false
}

// validateStructTags checks that every struct tag on exported fields is one
// that FromStruct actually reads. It returns an error listing all unrecognized
// tags so the caller can fix them all in one pass.
Expand Down Expand Up @@ -368,6 +379,7 @@ func (f *FlagSet) FromStruct(v any, opts ...FromStructOption) error {
defaultValue := field.Tag.Get("default")
envVar := field.Tag.Get("env")
required := field.Tag.Get("required") == "true"
hidden := isHiddenTag(field.Tag.Get("hidden"))

usage := field.Tag.Get("usage")
if usage == "" {
Expand Down Expand Up @@ -554,6 +566,7 @@ func (f *FlagSet) FromStruct(v any, opts ...FromStructOption) error {
if flag, ok := f.flags[longName]; ok {
flag.EnvVar = envVar
flag.Required = required
flag.Hidden = hidden
if envVar != "" {
if envVal, ok := os.LookupEnv(envVar); ok {
if err := flag.Value.Set(envVal); err != nil {
Expand Down
3 changes: 3 additions & 0 deletions help.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ func (f *FlagSet) WriteFlagHelp() {
var defaultFlags []*Flag

f.VisitAll(func(flag *Flag) {
if flag.Hidden {
return
}
if flag.Group == "" {
defaultFlags = append(defaultFlags, flag)
} else {
Expand Down
6 changes: 5 additions & 1 deletion help_doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type CommandDoc struct {
Path string `json:"path"`
Usage string `json:"usage"`
Description string `json:"description,omitempty"`
Group string `json:"group,omitempty"`
RequiredFeature string `json:"requiredFeature,omitempty"`
Flags []FlagDoc `json:"flags"`
FlagGroups []string `json:"flagGroups"`
Expand Down Expand Up @@ -46,6 +47,7 @@ type FlagDoc struct {
Choices []string `json:"choices,omitempty"`
EnvVar string `json:"envVar,omitempty"`
Required bool `json:"required,omitempty"`
Hidden bool `json:"hidden,omitempty"`
}

// PositionalDoc describes a positional argument in the help document.
Expand Down Expand Up @@ -87,6 +89,7 @@ func (f *FlagSet) HelpDoc() *FlagSetDoc {
Choices: []string{},
EnvVar: flag.EnvVar,
Required: flag.Required,
Hidden: flag.Hidden,
}
if flag.Short != 0 {
fd.Short = string(flag.Short)
Expand Down Expand Up @@ -135,13 +138,14 @@ func (d *Dispatcher) HelpJSON() ([]byte, error) {

// buildCommandDocs recursively builds CommandDoc entries for direct children of parentPath.
func (d *Dispatcher) buildCommandDocs(parentPath string) []CommandDoc {
children := d.getDirectChildren(parentPath)
children := d.GetDirectChildren(parentPath)
docs := make([]CommandDoc, 0, len(children))

for _, child := range children {
cmd := CommandDoc{
Path: child.Path,
Usage: child.Usage,
Group: child.Group,
Flags: []FlagDoc{},
FlagGroups: []string{},
PositionalArgs: []PositionalDoc{},
Expand Down
1 change: 1 addition & 0 deletions mflags.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ type Flag struct {
Group string // group name for help rendering; empty = default "Options:"
EnvVar string // environment variable name (from env:"..." struct tag)
Required bool // whether this flag must be provided
Hidden bool // omit from help output (from hidden:"yes" struct tag)
HasValue bool // true if value was set by env var or CLI arg
}

Expand Down
48 changes: 48 additions & 0 deletions mflags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3947,3 +3947,51 @@ func TestRequiredBoolFlag(t *testing.T) {
assert.True(t, config.Accept)
})
}

// --- hidden tag tests ---

func TestFromStructHiddenFlag(t *testing.T) {
type Config struct {
Visible string `long:"visible" description:"shown in help"`
LegacyYes string `long:"legacy-yes" description:"deprecated" hidden:"yes"`
LegacyTrue string `long:"legacy-true" description:"deprecated" hidden:"true"`
}

config := &Config{}
fs := NewFlagSet("test")
err := fs.FromStruct(config)
assert.NoError(t, err)

t.Run("flag is still parseable", func(t *testing.T) {
err := fs.Parse([]string{"--legacy-yes", "old"})
assert.NoError(t, err)
assert.Equal(t, "old", config.LegacyYes)
})

t.Run("hidden flag omitted from help output", func(t *testing.T) {
var buf bytes.Buffer
stdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
fs.WriteFlagHelp()
_ = w.Close()
os.Stdout = stdout
_, _ = io.Copy(&buf, r)

output := buf.String()
assert.Contains(t, output, "--visible")
assert.NotContains(t, output, "--legacy-yes")
assert.NotContains(t, output, "--legacy-true")
})

t.Run("hidden propagates to FlagDoc", func(t *testing.T) {
doc := fs.HelpDoc()
flagsByName := make(map[string]FlagDoc)
for _, f := range doc.Flags {
flagsByName[f.Name] = f
}
assert.False(t, flagsByName["visible"].Hidden)
assert.True(t, flagsByName["legacy-yes"].Hidden)
assert.True(t, flagsByName["legacy-true"].Hidden)
})
}
Loading