Skip to content

Commit 7485e47

Browse files
committed
fix(cli): make help and output modes predictable
1 parent 02291d2 commit 7485e47

13 files changed

Lines changed: 264 additions & 22 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Photos: add an explicit-opt-in Google Photos Picker workflow for creating selection sessions, waiting for completion, listing chosen media, and downloading selected files. (#754)
88
- Docs: add persisted, revision-locked request batches for composing supported mutations locally and submitting them atomically, with explicit split and partial-recovery modes. (#755)
99
- CLI: remove the separate `gog agent` and `exit-codes` helpers; expose stable exit codes and effective automation safety state through `gog schema --json`, and summarize the contract in root help. (#677)
10+
- CLI: add Git-style `gog help <command>`, make explicit output flags override environment defaults, validate color and JSON-only transforms before command execution, report early usage errors on stderr, and reject contradictory schema plain output.
1011

1112
## 0.24.0 - 2026-06-11
1213

docs/automation.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@ Root help summarizes the human-facing contract:
1212

1313
```bash
1414
gog --help
15+
gog help drive ls
1516
```
1617

18+
`gog help <command>` and `gog <command> --help` are equivalent. Once a help
19+
flag is present, trailing arguments are ignored so recovery help remains
20+
available after a malformed command attempt.
21+
1722
The machine-readable contract is:
1823

1924
```bash
@@ -34,6 +39,12 @@ gog --json gmail search 'newer_than:7d'
3439
gog --plain calendar events --today
3540
```
3641

42+
`--results-only` and `--select` transform JSON and therefore require
43+
`--json`. Contradictory output flags fail with usage exit code 2 instead of
44+
being silently ignored. Explicit output flags override `GOG_JSON` and
45+
`GOG_PLAIN` environment defaults. `gog schema` always emits JSON and rejects
46+
`--plain`.
47+
3748
Use `--no-input` in CI and unattended processes. Use `--wrap-untrusted` when
3849
Google-hosted free text will be consumed by an LLM or another instruction-aware
3950
system.

docs/commands.generated.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ Generated from `gog schema --json`.
174174
- [`gog classroom (class) students (student) add (create,new) <courseId> <userId> [flags]`](commands/gog-classroom-students-add.md) - Add a student
175175
- [`gog classroom (class) students (student) get (info,show) <courseId> <userId>`](commands/gog-classroom-students-get.md) - Get a student
176176
- [`gog classroom (class) students (student) list (ls) <courseId> [flags]`](commands/gog-classroom-students-list.md) - List students
177-
- [`gog classroom (class) students (student) remove (delete,rm,del,remove) <courseId> <userId>`](commands/gog-classroom-students-remove.md) - Remove a student
177+
- [`gog classroom (class) students (student) remove (delete,rm,del) <courseId> <userId>`](commands/gog-classroom-students-remove.md) - Remove a student
178178
- [`gog classroom (class) submissions (submission) <command>`](commands/gog-classroom-submissions.md) - Student submissions
179179
- [`gog classroom (class) submissions (submission) get (info,show) <courseId> <courseworkId> <submissionId>`](commands/gog-classroom-submissions-get.md) - Get a student submission
180180
- [`gog classroom (class) submissions (submission) grade (set,edit) <courseId> <courseworkId> <submissionId> [flags]`](commands/gog-classroom-submissions-grade.md) - Set draft/assigned grades
@@ -186,7 +186,7 @@ Generated from `gog schema --json`.
186186
- [`gog classroom (class) teachers (teacher) add (create,new) <courseId> <userId>`](commands/gog-classroom-teachers-add.md) - Add a teacher
187187
- [`gog classroom (class) teachers (teacher) get (info,show) <courseId> <userId>`](commands/gog-classroom-teachers-get.md) - Get a teacher
188188
- [`gog classroom (class) teachers (teacher) list (ls) <courseId> [flags]`](commands/gog-classroom-teachers-list.md) - List teachers
189-
- [`gog classroom (class) teachers (teacher) remove (delete,rm,del,remove) <courseId> <userId>`](commands/gog-classroom-teachers-remove.md) - Remove a teacher
189+
- [`gog classroom (class) teachers (teacher) remove (delete,rm,del) <courseId> <userId>`](commands/gog-classroom-teachers-remove.md) - Remove a teacher
190190
- [`gog classroom (class) topics (topic) <command>`](commands/gog-classroom-topics.md) - Topics
191191
- [`gog classroom (class) topics (topic) create (add,new) --name=STRING <courseId>`](commands/gog-classroom-topics-create.md) - Create a topic
192192
- [`gog classroom (class) topics (topic) delete (rm,del,remove) <courseId> <topicId>`](commands/gog-classroom-topics-delete.md) - Delete a topic

docs/commands/gog-classroom-students-remove.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Remove a student
77
## Usage
88

99
```bash
10-
gog classroom (class) students (student) remove (delete,rm,del,remove) <courseId> <userId>
10+
gog classroom (class) students (student) remove (delete,rm,del) <courseId> <userId>
1111
```
1212

1313
## Parent

docs/commands/gog-classroom-teachers-remove.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Remove a teacher
77
## Usage
88

99
```bash
10-
gog classroom (class) teachers (teacher) remove (delete,rm,del,remove) <courseId> <userId>
10+
gog classroom (class) teachers (teacher) remove (delete,rm,del) <courseId> <userId>
1111
```
1212

1313
## Parent

internal/cmd/classroom_rosters.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type ClassroomStudentsCmd struct {
1616
List ClassroomStudentsListCmd `cmd:"" default:"withargs" aliases:"ls" help:"List students"`
1717
Get ClassroomStudentsGetCmd `cmd:"" aliases:"info,show" help:"Get a student"`
1818
Add ClassroomStudentsAddCmd `cmd:"" aliases:"create,new" help:"Add a student"`
19-
Remove ClassroomStudentsRemoveCmd `cmd:"" aliases:"delete,rm,del,remove" help:"Remove a student"`
19+
Remove ClassroomStudentsRemoveCmd `cmd:"" aliases:"delete,rm,del" help:"Remove a student"`
2020
}
2121

2222
type ClassroomStudentsListCmd struct {
@@ -244,7 +244,7 @@ type ClassroomTeachersCmd struct {
244244
List ClassroomTeachersListCmd `cmd:"" default:"withargs" aliases:"ls" help:"List teachers"`
245245
Get ClassroomTeachersGetCmd `cmd:"" aliases:"info,show" help:"Get a teacher"`
246246
Add ClassroomTeachersAddCmd `cmd:"" aliases:"create,new" help:"Add a teacher"`
247-
Remove ClassroomTeachersRemoveCmd `cmd:"" aliases:"delete,rm,del,remove" help:"Remove a teacher"`
247+
Remove ClassroomTeachersRemoveCmd `cmd:"" aliases:"delete,rm,del" help:"Remove a teacher"`
248248
}
249249

250250
type ClassroomTeachersListCmd struct {

internal/cmd/desire_paths_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,28 @@ func TestDesirePaths_GlobalFlagAliases(t *testing.T) {
5959
}
6060
}
6161

62+
func TestDesirePaths_RewriteHelp(t *testing.T) {
63+
tests := []struct {
64+
name string
65+
in []string
66+
want []string
67+
}{
68+
{name: "root", in: []string{"help"}, want: []string{"--help"}},
69+
{name: "command", in: []string{"help", "drive", "ls"}, want: []string{"drive", "ls", "--help"}},
70+
{name: "global flag", in: []string{"--color", "never", "help", "gmail"}, want: []string{"--color", "never", "gmail", "--help"}},
71+
{name: "help ignores trailing args", in: []string{"drive", "--help", "nonsense"}, want: []string{"drive", "--help"}},
72+
{name: "help after delimiter is data", in: []string{"open", "--", "--help"}, want: []string{"open", "--", "--help"}},
73+
{name: "global value named help", in: []string{"--account", "help", "version"}, want: []string{"--account", "help", "version"}},
74+
}
75+
for _, tt := range tests {
76+
t.Run(tt.name, func(t *testing.T) {
77+
if got := rewriteHelpArgs(tt.in); !reflect.DeepEqual(got, tt.want) {
78+
t.Fatalf("rewriteHelpArgs(%v) = %v, want %v", tt.in, got, tt.want)
79+
}
80+
})
81+
}
82+
}
83+
6284
func TestDesirePaths_DryRunAlias_ExitsBeforeAuth(t *testing.T) {
6385
out := captureStdout(t, func() {
6486
_ = captureStderr(t, func() {

internal/cmd/help_printer.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ func injectAutomationHelp(out string, selected *kong.Node) string {
105105

106106
const section = `Automation:
107107
Use --json or --plain for stable output; --no-input disables prompts.
108+
Use "gog help <command>" or "gog <command> --help" for command help.
108109
Exit codes: 0 success, 1 error, 2 usage, 3 empty, 4 auth, 5 not found,
109110
6 denied, 7 rate limited, 8 retryable, 10 config, 11 orphaned,
110111
130 interrupted.

internal/cmd/help_snapshot_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ func TestHelpSnapshot_RootAutomationContract(t *testing.T) {
9191
requireHelpContains(t, out,
9292
"\nAutomation:\n",
9393
"Use --json or --plain for stable output",
94+
`Use "gog help <command>"`,
9495
"Exit codes: 0 success",
9596
`Run "gog schema --json"`,
9697
)

internal/cmd/root.go

Lines changed: 95 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -108,22 +108,23 @@ func Execute(args []string) (err error) {
108108
if len(args) == 0 {
109109
args = []string{"--help"}
110110
}
111+
args = rewriteHelpArgs(args)
111112
args = rewriteDesirePathArgs(args)
112113
args = rewriteDocsCellUpdateContentArgs(args)
113114

114115
preHomeApplied := false
115116
if home, ok := preScanHomeArg(args); ok {
116117
restoreHome, homeErr := config.SetHomeOverride(home)
117118
if homeErr != nil {
118-
return newUsageError(homeErr)
119+
return reportEarlyError(newUsageError(homeErr))
119120
}
120121
preHomeApplied = true
121122
defer restoreHome()
122123
}
123124

124125
parser, cli, err := newParser(helpDescription())
125126
if err != nil {
126-
return err
127+
return reportEarlyError(err)
127128
}
128129

129130
defer func() {
@@ -142,33 +143,28 @@ func Execute(args []string) (err error) {
142143

143144
kctx, err := parser.Parse(args)
144145
if err != nil {
145-
parsedErr := wrapParseError(err)
146-
_, _ = fmt.Fprintln(os.Stderr, errfmt.Format(parsedErr))
147-
return parsedErr
146+
return reportEarlyError(wrapParseError(err))
148147
}
148+
applyExplicitOutputModePrecedence(kctx, &cli.RootFlags)
149149
if !preHomeApplied && strings.TrimSpace(cli.Home) != "" {
150150
restoreHome, homeErr := config.SetHomeOverride(cli.Home)
151151
if homeErr != nil {
152-
return newUsageError(homeErr)
152+
return reportEarlyError(newUsageError(homeErr))
153153
}
154154
defer restoreHome()
155155
}
156156

157157
if err = enforceBakedSafetyProfile(kctx); err != nil {
158-
_, _ = fmt.Fprintln(os.Stderr, errfmt.Format(err))
159-
return err
158+
return reportEarlyError(err)
160159
}
161160
if err = enforceEnabledCommands(kctx, cli.EnableCommands, cli.EnableCommandsExact); err != nil {
162-
_, _ = fmt.Fprintln(os.Stderr, errfmt.Format(err))
163-
return err
161+
return reportEarlyError(err)
164162
}
165163
if err = enforceDisabledCommands(kctx, cli.DisableCommands); err != nil {
166-
_, _ = fmt.Fprintln(os.Stderr, errfmt.Format(err))
167-
return err
164+
return reportEarlyError(err)
168165
}
169166
if err = enforceGmailNoSend(kctx, &cli.RootFlags); err != nil {
170-
_, _ = fmt.Fprintln(os.Stderr, errfmt.Format(err))
171-
return err
167+
return reportEarlyError(err)
172168
}
173169

174170
logLevel := slog.LevelWarn
@@ -187,7 +183,11 @@ func Execute(args []string) (err error) {
187183

188184
mode, err := outfmt.FromFlags(cli.JSON, cli.Plain)
189185
if err != nil {
190-
return newUsageError(err)
186+
return reportEarlyError(newUsageError(err))
187+
}
188+
err = validateJSONTransformFlags(mode, &cli.RootFlags)
189+
if err != nil {
190+
return reportEarlyError(err)
191191
}
192192

193193
ctx := context.Background()
@@ -216,7 +216,7 @@ func Execute(args []string) (err error) {
216216
Color: uiColor,
217217
})
218218
if err != nil {
219-
return err
219+
return reportEarlyError(newUsageError(err))
220220
}
221221
ctx = ui.WithUI(ctx, u)
222222

@@ -247,6 +247,85 @@ func Execute(args []string) (err error) {
247247
return err
248248
}
249249

250+
func rewriteHelpArgs(args []string) []string {
251+
for i, arg := range args {
252+
if arg == "--" {
253+
break
254+
}
255+
if arg == "--help" || arg == "-h" {
256+
return append([]string(nil), args[:i+1]...)
257+
}
258+
}
259+
260+
for i := 0; i < len(args); i++ {
261+
arg := args[i]
262+
if arg == "--" {
263+
return args
264+
}
265+
if strings.HasPrefix(arg, "-") {
266+
if globalFlagTakesValue(arg) && i+1 < len(args) {
267+
i++
268+
}
269+
continue
270+
}
271+
if arg != "help" {
272+
return args
273+
}
274+
275+
out := make([]string, 0, len(args))
276+
out = append(out, args[:i]...)
277+
out = append(out, args[i+1:]...)
278+
out = append(out, "--help")
279+
return out
280+
}
281+
return args
282+
}
283+
284+
func validateJSONTransformFlags(mode outfmt.Mode, flags *RootFlags) error {
285+
if flags == nil || mode.JSON {
286+
return nil
287+
}
288+
289+
hasResultsOnly := flags.ResultsOnly
290+
hasSelect := strings.TrimSpace(flags.Select) != ""
291+
switch {
292+
case hasResultsOnly && hasSelect:
293+
return usage("--results-only and --select require --json")
294+
case hasResultsOnly:
295+
return usage("--results-only requires --json")
296+
case hasSelect:
297+
return usage("--select requires --json")
298+
default:
299+
return nil
300+
}
301+
}
302+
303+
func applyExplicitOutputModePrecedence(kctx *kong.Context, flags *RootFlags) {
304+
if flags == nil {
305+
return
306+
}
307+
308+
jsonSet := flagProvided(kctx, "json")
309+
plainSet := flagProvided(kctx, "plain")
310+
switch {
311+
case jsonSet && !plainSet:
312+
flags.Plain = false
313+
case plainSet && !jsonSet:
314+
flags.JSON = false
315+
}
316+
}
317+
318+
func reportEarlyError(err error) error {
319+
if err == nil {
320+
return nil
321+
}
322+
msg := strings.TrimSpace(errfmt.Format(err))
323+
if msg != "" {
324+
_, _ = fmt.Fprintln(os.Stderr, msg)
325+
}
326+
return err
327+
}
328+
250329
func rewriteDesirePathArgs(args []string) []string {
251330
// Some commands use `--fields` for API field masks. Agents also frequently
252331
// guess `--fields` to mean "select output fields", so we squat it everywhere

0 commit comments

Comments
 (0)