Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ All notable changes to this project will be documented in this file.
- **drive**: Add modified-time smart sync mode (#859)
- **approval**: Add `tasks.add_sign` and `tasks.rollback` methods (#867)

### Bug Fixes

- **commands**: Return structured errors for unsupported top-level commands and `+shortcut` invocations instead of silently showing parent help

## [v1.0.30] - 2026-05-13

### Features
Expand Down
206 changes: 206 additions & 0 deletions cmd/command_preflight.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package cmd

import (
"fmt"
"strings"

"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

const maxShortcutHintItems = 8

func validateCommandInvocation(root *cobra.Command, args []string) error {
if token, ok := firstRootCommandToken(root, args); ok {
if _, found := findTopLevelCommand(root, token); !found {
return unknownCommandError(root, token)
}
}

// Traverse can still fail for ordinary Cobra errors (for example unknown
// flags). Discard that error here and leave it on the normal Execute path
// so this preflight only replaces silent parent-help fallbacks with
// structured validation errors.
cmd, remaining, _ := root.Traverse(args)
if cmd == nil || len(remaining) == 0 {
return nil
}
unknown := remaining[0]
if !strings.HasPrefix(unknown, "+") {
return nil
}
available := availableShortcutCommands(cmd.Name())
if len(available) == 0 {
return nil

Check warning on line 40 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L40

Added line #L40 was not covered by tests
}

commandPath := cmd.CommandPath()
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_shortcut",
Message: fmt.Sprintf("shortcut %q is not supported for %q", unknown, commandPath),
Hint: fmt.Sprintf("available shortcuts: %s; run `%s --help` to see all", shortcutHintList(available), commandPath),
Detail: map[string]interface{}{
"shortcut": unknown,
"service": cmd.Name(),
"available_shortcuts": available,
},
},
}
}

func firstRootCommandToken(root *cobra.Command, args []string) (string, bool) {
flags := root.PersistentFlags()
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "" {
continue

Check warning on line 64 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L64

Added line #L64 was not covered by tests
}
if arg == "--" {
if i+1 < len(args) {
return args[i+1], true

Check warning on line 68 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L67-L68

Added lines #L67 - L68 were not covered by tests
}
return "", false

Check warning on line 70 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L70

Added line #L70 was not covered by tests
}
if arg == "--help" || arg == "-h" {
return "", false
}
if strings.HasPrefix(arg, "--") {
flagName := strings.TrimPrefix(arg, "--")
hasInlineValue := false
if idx := strings.Index(flagName, "="); idx >= 0 {
flagName = flagName[:idx]
hasInlineValue = true

Check warning on line 80 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L76-L80

Added lines #L76 - L80 were not covered by tests
}
flag := flags.Lookup(flagName)
if flag == nil {
return "", false

Check warning on line 84 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L82-L84

Added lines #L82 - L84 were not covered by tests
}
if !hasInlineValue && flagConsumesValue(flag) {
i++

Check warning on line 87 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L86-L87

Added lines #L86 - L87 were not covered by tests
}
continue

Check warning on line 89 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L89

Added line #L89 was not covered by tests
}
if strings.HasPrefix(arg, "-") && arg != "-" {
if len(arg) != 2 {
return "", false

Check warning on line 93 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L92-L93

Added lines #L92 - L93 were not covered by tests
}
flag := flags.ShorthandLookup(arg[1:])
if flag == nil {
return "", false

Check warning on line 97 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L95-L97

Added lines #L95 - L97 were not covered by tests
}
if flagConsumesValue(flag) {
i++

Check warning on line 100 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L99-L100

Added lines #L99 - L100 were not covered by tests
}
continue

Check warning on line 102 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L102

Added line #L102 was not covered by tests
}
return arg, true
}
return "", false
}

func flagConsumesValue(flag *pflag.Flag) bool {
return flag != nil && flag.NoOptDefVal == ""

Check warning on line 110 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L109-L110

Added lines #L109 - L110 were not covered by tests
}

func findTopLevelCommand(root *cobra.Command, token string) (*cobra.Command, bool) {
if token == "help" {
return nil, true

Check warning on line 115 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L115

Added line #L115 was not covered by tests
}
for _, cmd := range root.Commands() {
if cmd.Name() == token || cmd.HasAlias(token) {
return cmd, true
}
}
return nil, false
}

func unknownCommandError(root *cobra.Command, token string) error {
available := availableTopLevelCommands(root)
services := availableServiceCommands(root)
commandPath := root.CommandPath()
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_command",
Message: fmt.Sprintf("command %q is not supported for %q", token, commandPath),
Hint: fmt.Sprintf("available commands: %s; run `%s --help` to see all", shortcutHintList(available), commandPath),
Detail: map[string]interface{}{
"command": token,
"scope": "root",
"available_commands": available,
"available_services": services,
},
},
}
}

func availableTopLevelCommands(root *cobra.Command) []string {
out := make([]string, 0)
for _, cmd := range root.Commands() {
if cmd.Hidden {
continue

Check warning on line 149 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L149

Added line #L149 was not covered by tests
}
out = append(out, cmd.Name())
}
return out
}

func availableServiceCommands(root *cobra.Command) []string {
registered := map[string]struct{}{}
for _, cmd := range root.Commands() {
registered[cmd.Name()] = struct{}{}
}
seen := map[string]struct{}{}
out := make([]string, 0)
for _, project := range registry.ListFromMetaProjects() {
spec := registry.LoadFromMeta(project)
if spec == nil {
continue

Check warning on line 166 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L166

Added line #L166 was not covered by tests
}
name := registry.GetStrFromMap(spec, "name")
if name == "" {
continue

Check warning on line 170 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L170

Added line #L170 was not covered by tests
}
if _, ok := registered[name]; !ok {
continue

Check warning on line 173 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L173

Added line #L173 was not covered by tests
}
if _, ok := seen[name]; ok {
continue

Check warning on line 176 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L176

Added line #L176 was not covered by tests
}
seen[name] = struct{}{}
out = append(out, name)
}
return out
}

func availableShortcutCommands(service string) []string {
seen := map[string]struct{}{}
out := make([]string, 0)
for _, shortcut := range shortcuts.AllShortcuts() {
if shortcut.Service != service || shortcut.Hidden {
continue
}
if _, ok := seen[shortcut.Command]; ok {
continue

Check warning on line 192 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L192

Added line #L192 was not covered by tests
}
seen[shortcut.Command] = struct{}{}
out = append(out, shortcut.Command)
}
return out
}

func shortcutHintList(commands []string) string {
if len(commands) <= maxShortcutHintItems {
return strings.Join(commands, ", ")

Check warning on line 202 in cmd/command_preflight.go

View check run for this annotation

Codecov / codecov/patch

cmd/command_preflight.go#L202

Added line #L202 was not covered by tests
}
head := strings.Join(commands[:maxShortcutHintItems], ", ")
return fmt.Sprintf("%s, ...and %d more", head, len(commands)-maxShortcutHintItems)
}
3 changes: 3 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@

// --- Notices (non-blocking) ---
if !isCompletionCommand(os.Args) {
if err := validateCommandInvocation(rootCmd, os.Args[1:]); err != nil {
return handleRootError(f, err)

Check warning on line 100 in cmd/root.go

View check run for this annotation

Codecov / codecov/patch

cmd/root.go#L99-L100

Added lines #L99 - L100 were not covered by tests
}
setupNotices()
}

Expand Down
Loading
Loading