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
64 changes: 44 additions & 20 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,21 +266,28 @@ func renderCommandHelp(cmd *cobra.Command) {
}
}

// FLAGS (all local flags: persistent + non-persistent)
localFlags := cmd.LocalFlags()
localUsage := strings.TrimRight(localFlags.FlagUsages(), "\n")
if localUsage != "" {
// FLAGS — local flags plus parent-scoped persistent flags (e.g. --chat,
// --project defined on a parent command). This promotes parent-scoped
// flags into the primary FLAGS section where they're immediately visible,
// rather than burying them in INHERITED FLAGS alongside root globals.
merged := pflag.NewFlagSet("flags", pflag.ContinueOnError)
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { merged.AddFlag(f) })
parentScopedFlags(cmd).VisitAll(func(f *pflag.Flag) {
if merged.Lookup(f.Name) == nil {
merged.AddFlag(f)
}
})
flagsUsage := strings.TrimRight(merged.FlagUsages(), "\n")
if flagsUsage != "" {
b.WriteString("\n")
b.WriteString(r.Header.Render("FLAGS"))
b.WriteString("\n")
b.WriteString(localUsage)
b.WriteString(flagsUsage)
b.WriteString("\n")
}

// INHERITED FLAGS
// Parent-defined persistent flags (--project, --chat, etc.) always
// show — they carry required context. Root-level global flags are curated
// to the essentials so leaf help isn't 20+ lines of noise.
// INHERITED FLAGS — root-level globals only. Parent-scoped persistent
// flags (--project, --chat, etc.) are promoted into FLAGS above.
inherited := filterInheritedFlags(cmd)
if inherited != "" {
b.WriteString("\n")
Expand Down Expand Up @@ -314,8 +321,8 @@ func renderCommandHelp(cmd *cobra.Command) {
}

// salientRootFlags is the curated set of root-level global flags shown in
// inherited flag sections. Parent-defined persistent flags always appear;
// only root globals are filtered to this set.
// INHERITED FLAGS. Non-root parent-scoped persistent flags are promoted
// into the FLAGS section by parentScopedFlags and never appear here.
var salientRootFlags = map[string]bool{
"account": true,
"json": true,
Expand All @@ -324,13 +331,27 @@ var salientRootFlags = map[string]bool{
"quiet": true,
}

// filterInheritedFlags returns formatted flag usages for inherited flags,
// keeping all parent-defined persistent flags and curating root globals
// to the salient set. Provenance is determined by pointer identity: if the
// flag object is the same pointer as the one on root's PersistentFlags,
// it truly originates from root. A parent that redefines the same name
// (e.g. --project on messages) produces a different pointer and always
// passes through.
// parentScopedFlags returns inherited flags that originate from a non-root
// parent command. These are promoted into the FLAGS section so they're
// immediately visible on leaf commands, rather than buried in INHERITED FLAGS.
// Provenance is determined by pointer identity against root's PersistentFlags.
func parentScopedFlags(cmd *cobra.Command) *pflag.FlagSet {
root := cmd.Root()
ps := pflag.NewFlagSet("parent-scoped", pflag.ContinueOnError)
cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) {
rootFlag := root.PersistentFlags().Lookup(f.Name)
if rootFlag != nil && rootFlag == f {
return // root-level — stays in INHERITED FLAGS
}
ps.AddFlag(f)
})
return ps
}

// filterInheritedFlags returns formatted flag usages for INHERITED FLAGS,
// containing only the curated subset of root-level globals. Parent-scoped
// persistent flags (--chat, --project on messages, etc.) are excluded here
// because parentScopedFlags promotes them into FLAGS.
func filterInheritedFlags(cmd *cobra.Command) string {
root := cmd.Root()
filtered := pflag.NewFlagSet("inherited", pflag.ContinueOnError)
Expand All @@ -341,10 +362,13 @@ func filterInheritedFlags(cmd *cobra.Command) string {

cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) {
rootFlag := root.PersistentFlags().Lookup(f.Name)
if rootFlag != nil && rootFlag == f && !salientRootFlags[f.Name] {
if rootFlag == nil || rootFlag != f {
return // parent-scoped — already promoted to FLAGS
}
if !salientRootFlags[f.Name] {
return
}
if acceptsID && f.Name == "project" && rootFlag == f {
if acceptsID && f.Name == "project" {
return
}
filtered.AddFlag(f)
Expand Down
89 changes: 80 additions & 9 deletions internal/cli/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,11 @@ func TestRootLevelLeafCommandHelp(t *testing.T) {
assert.NotContains(t, out, "COMMANDS")
}

func TestLeafCommandInheritsParentPersistentFlags(t *testing.T) {
// Leaf commands must show parent-defined persistent flags in INHERITED
// FLAGS. These flags carry required context (--project, --chat, etc.)
// and hiding them breaks discoverability.
func TestLeafCommandShowsParentScopedFlagsInFLAGS(t *testing.T) {
// Parent-scoped persistent flags (--project, --chat, etc.) are promoted
// into the FLAGS section on leaf commands, not buried in INHERITED FLAGS.
// This is a renderer-wide policy: any leaf whose parent defines persistent
// flags will show them in FLAGS.
isolateHelpTest(t)

tests := []struct {
Expand All @@ -197,19 +198,19 @@ func TestLeafCommandInheritsParentPersistentFlags(t *testing.T) {
wantFlags []string
}{
{
"messages create inherits --project",
"messages create shows --project in FLAGS",
[]string{"messages", "create", "--help"},
commands.NewMessagesCmd,
[]string{"--project", "--in", "--message-board"},
},
{
"chat post inherits --project and --chat",
"chat post shows --project and --chat in FLAGS",
[]string{"chat", "post", "--help"},
commands.NewChatCmd,
[]string{"--project", "--chat", "--content-type"},
[]string{"--project", "--chat"},
},
{
"timesheet report inherits date and person flags",
"timesheet report shows date and person flags in FLAGS",
[]string{"timesheet", "report", "--help"},
commands.NewTimesheetCmd,
[]string{"--project", "--start", "--end", "--person"},
Expand All @@ -226,13 +227,83 @@ func TestLeafCommandInheritsParentPersistentFlags(t *testing.T) {
_ = cmd.Execute()

out := buf.String()
flagsSection := extractSection(out, "FLAGS")
for _, flag := range tt.wantFlags {
assert.Contains(t, out, flag)
assert.Contains(t, flagsSection, flag,
"expected %s in FLAGS section", flag)
}
})
}
}

func TestCampfirePostHelpShowsChatFlag(t *testing.T) {
// The campfire alias path must also show --chat in FLAGS.
isolateHelpTest(t)

var buf bytes.Buffer
cmd := NewRootCmd()
cmd.AddCommand(commands.NewChatCmd())
cmd.SetOut(&buf)
cmd.SetArgs([]string{"campfire", "post", "--help"})
_ = cmd.Execute()

out := buf.String()
flagsSection := extractSection(out, "FLAGS")
assert.Contains(t, flagsSection, "--chat")
assert.Contains(t, flagsSection, "--project")
}

func TestLeafCommandKeepsRootGlobalsInInheritedFlags(t *testing.T) {
// Root-level globals (--json, --quiet, etc.) must stay in INHERITED FLAGS,
// not be promoted into FLAGS. This is the other half of the parent-scoped
// promotion contract.
isolateHelpTest(t)

var buf bytes.Buffer
cmd := NewRootCmd()
cmd.AddCommand(commands.NewProjectsCmd())
cmd.SetOut(&buf)
cmd.SetArgs([]string{"projects", "list", "--help"})
_ = cmd.Execute()

out := buf.String()
inheritedSection := extractSection(out, "INHERITED FLAGS")
flagsSection := extractSection(out, "FLAGS")

assert.Contains(t, inheritedSection, "--json",
"--json should remain in INHERITED FLAGS")
assert.Contains(t, inheritedSection, "--quiet",
"--quiet should remain in INHERITED FLAGS")
assert.NotContains(t, flagsSection, "--json",
"--json should not be promoted to FLAGS")
assert.NotContains(t, flagsSection, "--quiet",
"--quiet should not be promoted to FLAGS")
}

// extractSection returns the text between a header (e.g. "FLAGS") and the
// next header or end of string. Headers are identified as lines that are
// all-caps words (the styled header text).
func extractSection(help, header string) string {
lines := strings.Split(help, "\n")
var collecting bool
var section strings.Builder
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == header {
collecting = true
continue
}
if collecting && trimmed != "" && trimmed == strings.ToUpper(trimmed) && !strings.HasPrefix(trimmed, "-") {
break // next header
}
if collecting {
section.WriteString(line)
section.WriteString("\n")
}
}
return section.String()
}

func TestChatAliasShowsChatHelp(t *testing.T) {
// Invoking via the old alias "campfire" should still render canonical
// "basecamp chat" in the help output.
Expand Down
Loading