From 906675ae365f7f8f34e6fc20974926f85ed59c01 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 28 Oct 2026 09:05:12 -0400 Subject: [PATCH 1/2] Leave refused container flags out of move and copy help A move or copy command refuses a container flag it inherits but never reads, yet its help still listed the flag: messages copy --help offered --message-board as "required if project has multiple", and following it failed with "does not read --message-board". 28 such flags showed across 20 commands. Help now skips any parent-scoped flag the guard refuses, decided by the same FilingIgnores rule, so the two cannot drift. The refusal's hint also says to drop the flag, not only how to name another destination. --- internal/cli/help.go | 3 +++ internal/commands/filing.go | 16 +++++++++++---- internal/commands/filing_guard_test.go | 27 ++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/internal/cli/help.go b/internal/cli/help.go index 5890d78a2..05bae0159 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -411,6 +411,9 @@ func parentScopedFlags(cmd *cobra.Command) *pflag.FlagSet { if rootFlag != nil && rootFlag == f { return // root-level — stays in INHERITED FLAGS } + if commands.FilingIgnores(cmd, f) { + return + } ps.AddFlag(f) }) return ps diff --git a/internal/commands/filing.go b/internal/commands/filing.go index 533272971..67efb00e1 100644 --- a/internal/commands/filing.go +++ b/internal/commands/filing.go @@ -652,7 +652,7 @@ func guardFilingFlags(cmd *cobra.Command, reads ...string) *cobra.Command { cmd.Annotations[AnnotationFilingReads] = strings.Join(reads, " ") run := cmd.RunE cmd.RunE = func(cmd *cobra.Command, args []string) error { - if err := refuseUnreadContainerFlags(cmd, reads); err != nil { + if err := refuseUnreadContainerFlags(cmd); err != nil { return err } return run(cmd, args) @@ -660,10 +660,10 @@ func guardFilingFlags(cmd *cobra.Command, reads ...string) *cobra.Command { return cmd } -func refuseUnreadContainerFlags(cmd *cobra.Command, reads []string) error { +func refuseUnreadContainerFlags(cmd *cobra.Command) error { var unread *pflag.Flag cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) { - if unread == nil && f.Changed && isContainerFlag(cmd, f) && !slices.Contains(reads, f.Name) { + if unread == nil && f.Changed && FilingIgnores(cmd, f) { unread = f } }) @@ -672,11 +672,19 @@ func refuseUnreadContainerFlags(cmd *cobra.Command, reads []string) error { } hint := fmt.Sprintf("%s takes no destination; drop --%s", cmd.CommandPath(), unread.Name) if dests := destinationFlags(cmd); len(dests) > 0 { - hint = fmt.Sprintf("Name where it goes with %s (see %s --help)", strings.Join(dests, " or "), cmd.CommandPath()) + hint = fmt.Sprintf("Drop --%s; to send it somewhere else, name where it goes with %s (see %s --help)", unread.Name, strings.Join(dests, " or "), cmd.CommandPath()) } return output.ErrUsageHint(fmt.Sprintf("%s does not read --%s, so it would ignore it", cmd.CommandPath(), unread.Name), hint) } +// FilingIgnores reports whether cmd moves or copies recordings and refuses the +// inherited flag f as a container flag it never reads. Help leaves such flags +// out, so it never offers one the command would refuse. +func FilingIgnores(cmd *cobra.Command, f *pflag.Flag) bool { + reads, guarded := cmd.Annotations[AnnotationFilingReads] + return guarded && isContainerFlag(cmd, f) && !slices.Contains(strings.Fields(reads), f.Name) +} + func isContainerFlag(cmd *cobra.Command, f *pflag.Flag) bool { switch f.Name { case "project", "in": diff --git a/internal/commands/filing_guard_test.go b/internal/commands/filing_guard_test.go index 73df77173..63d8be9d3 100644 --- a/internal/commands/filing_guard_test.go +++ b/internal/commands/filing_guard_test.go @@ -5,6 +5,7 @@ import ( "context" "io" "net/http" + "regexp" "slices" "sort" "strings" @@ -117,6 +118,32 @@ func TestFilingCommandsRefuseContainerFlagsTheyDoNotRead(t *testing.T) { assert.Positive(t, rows) } +// Help is how people and agents pick flags, so it must not offer one the +// command refuses: messages copy --help said --message-board was "required if +// project has multiple", and every copy that followed it failed. +func TestFilingCommandHelpOffersOnlyTheContainerFlagsItReads(t *testing.T) { + t.Setenv("BASECAMP_NO_KEYRING", "1") + hidden := 0 + for path, reads := range filingReads { + var buf bytes.Buffer + root := buildRootWithAllCommands() + root.SetOut(&buf) + root.SetArgs(append(strings.Fields(path)[1:], "--help")) + require.NoError(t, root.Execute()) + help := buf.String() + for _, flag := range inheritedContainerFlags(t, path) { + offered := regexp.MustCompile(`(?m)^\s+(-\w, )?--` + regexp.QuoteMeta(flag) + `\s`).MatchString(help) + if slices.Contains(reads, flag) { + assert.True(t, offered, "%s --help leaves out --%s, which it reads", path, flag) + continue + } + hidden++ + assert.False(t, offered, "%s --help offers --%s, which it refuses", path, flag) + } + } + assert.Positive(t, hidden) +} + func TestTodosCopyRefusesTheTodolistFlagItWouldIgnore(t *testing.T) { for name, args := range map[string][]string{ "after the command": {"todos", "copy", "456", "--todolist", "901"}, From 16cced9f2b2d5a26fa6a003e93fd306f3651e342 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 28 Oct 2026 09:13:54 -0400 Subject: [PATCH 2/2] Acknowledge the refused container flags leaving move and copy help The surface diff reads agent help, so flags help no longer offers show as removals. Each was already refused as a usage error since v0.20.0. --- .surface-breaking | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/.surface-breaking b/.surface-breaking index 4ca0e78c5..579aa7a82 100644 --- a/.surface-breaking +++ b/.surface-breaking @@ -1513,3 +1513,61 @@ ARG basecamp folders move 00 ARG basecamp vault move 00 ARG basecamp vaults move 00 ARG basecamp tools move 00 +FLAG basecamp cards column copy --card-table type=string +FLAG basecamp cards copy --card-table type=string +FLAG basecamp checkin question copy --questionnaire type=string +FLAG basecamp checkin question move --questionnaire type=string +FLAG basecamp checkins question copy --questionnaire type=string +FLAG basecamp checkins question move --questionnaire type=string +FLAG basecamp docs copy --folder type=string +FLAG basecamp docs copy --vault type=string +FLAG basecamp docs move --folder type=string +FLAG basecamp docs move --vault type=string +FLAG basecamp documents copy --folder type=string +FLAG basecamp documents copy --vault type=string +FLAG basecamp documents move --folder type=string +FLAG basecamp documents move --vault type=string +FLAG basecamp file copy --folder type=string +FLAG basecamp file copy --vault type=string +FLAG basecamp file move --folder type=string +FLAG basecamp file move --vault type=string +FLAG basecamp files copy --folder type=string +FLAG basecamp files copy --vault type=string +FLAG basecamp files move --folder type=string +FLAG basecamp files move --vault type=string +FLAG basecamp folders copy --folder type=string +FLAG basecamp folders copy --vault type=string +FLAG basecamp folders move --folder type=string +FLAG basecamp folders move --vault type=string +FLAG basecamp forwards copy --inbox type=string +FLAG basecamp forwards move --inbox type=string +FLAG basecamp messages copy --message-board type=string +FLAG basecamp messages move --message-board type=string +FLAG basecamp msgs copy --message-board type=string +FLAG basecamp msgs move --message-board type=string +FLAG basecamp schedule copy --schedule type=string +FLAG basecamp schedule move --schedule type=string +FLAG basecamp tlgroup copy --list type=string +FLAG basecamp tlgroup move --list type=string +FLAG basecamp tlgroup position --list type=string +FLAG basecamp tlgroups copy --list type=string +FLAG basecamp tlgroups move --list type=string +FLAG basecamp tlgroups position --list type=string +FLAG basecamp todolistgroup copy --list type=string +FLAG basecamp todolistgroup move --list type=string +FLAG basecamp todolistgroup position --list type=string +FLAG basecamp todolistgroups copy --list type=string +FLAG basecamp todolistgroups move --list type=string +FLAG basecamp todolistgroups position --list type=string +FLAG basecamp uploads copy --folder type=string +FLAG basecamp uploads copy --vault type=string +FLAG basecamp uploads move --folder type=string +FLAG basecamp uploads move --vault type=string +FLAG basecamp vault copy --folder type=string +FLAG basecamp vault copy --vault type=string +FLAG basecamp vault move --folder type=string +FLAG basecamp vault move --vault type=string +FLAG basecamp vaults copy --folder type=string +FLAG basecamp vaults copy --vault type=string +FLAG basecamp vaults move --folder type=string +FLAG basecamp vaults move --vault type=string