Files
larksuite__cli/shortcuts/sheets/flag_ergonomics.go
xiongyuanwen-byted 7ee6705490 feat(sheets): add sheets-scoped flag ergonomics via PostMount
Two recovery loops from the edit-eval traces burn agent round-trips:
hallucinated flag names (--cols for --range) whose unknown-flag error
only points at --help, and enum values imported from CSS/Excel
vocabulary ("center" for the vertical alignment Lark spells "middle").

- unknown-flag errors now inline the full valid-flag list (semantic
  guesses aren't rankable by edit distance; kills the --help round trip)
- enum values with an unambiguous canonical form (casing, known alias)
  are normalized in place and the call proceeds; edit-distance typos
  stay errors with a did-you-mean hint and are never auto-applied

Both ride the existing PostMount composition (same pattern as
withTokenAlias), so the common framework is untouched and no other
domain's behavior shifts.
2026-07-09 16:44:33 +08:00

239 lines
8.4 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"fmt"
"slices"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// ─── sheets flag ergonomics ─────────────────────────────────────────────
//
// Eval traces show two recovery loops that burn agent round-trips on the
// sheets domain specifically: hallucinated flag names (--cols for --range,
// --file for --csv) whose unknown-flag error only points at --help, and
// enum values imported from CSS / Excel vocabulary ("center" for the
// vertical alignment Lark spells "middle"). Both fixes are wired through
// the existing PostMount hook — composed onto any prior PostMount in
// Shortcuts(), same pattern as withTokenAlias — so the common framework
// needs no change at all and no other domain's behavior shifts.
// withFlagErgonomics wraps an optional PostMount so that, after it runs,
// the command gets the sheets-specific unknown-flag error (valid flags
// inlined) and enum-value normalization (canonical vocabulary auto-applied,
// typos suggested).
func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
return func(cmd *cobra.Command) {
if prev != nil {
prev(cmd)
}
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
chainEnumNormalization(cmd)
}
}
// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands.
// It keeps the root behavior (typed error, did-you-mean suggestions, the
// offending flag on params) and additionally inlines the full valid-flag
// set: hallucinated sheets flags are usually semantic guesses (--cols for
// --range) that edit distance can't rank, and a --help round trip costs an
// agent a full extra call. One line here lets it re-issue the command
// immediately.
func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagFromParseError(ferr)
if !isUnknown {
return common.ValidationErrorf("%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if list := inlineFlagList(valid); list != "" {
hint = "valid flags: " + list
if len(suggestions) > 0 {
hint = fmt.Sprintf("did you mean %s? valid flags: %s",
strings.Join(suggestions, ", "), list)
}
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
WithHint("%s", hint)
}
// unknownFlagFromParseError extracts the offending long-flag name from
// cobra's flag-parse error text ("unknown flag: --query" → "query").
// Returns ok=false for anything else (missing argument, invalid value,
// unknown shorthand) so those stay structured but generic. Mirrors the
// root-level parser in cmd; the prefix contract is cobra's English wording.
func unknownFlagFromParseError(err error) (string, bool) {
const p = "unknown flag: --"
msg := err.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
}
rest := msg[i+len(p):]
if j := strings.IndexAny(rest, " \t"); j >= 0 {
rest = rest[:j]
}
return rest, true
}
// visibleFlagNames lists the non-hidden flag names registered on c, sorted.
func visibleFlagNames(c *cobra.Command) []string {
var names []string
c.Flags().VisitAll(func(f *pflag.Flag) {
if !f.Hidden {
names = append(names, f.Name)
}
})
sort.Strings(names)
return names
}
// inlineFlagListLimit caps how many flag names ride inline on an
// unknown-flag hint. Sheets shortcuts stay well under it.
const inlineFlagListLimit = 25
// inlineFlagList renders valid flag names as one comma-separated line for
// the unknown-flag hint, truncating past inlineFlagListLimit. Empty when
// there is nothing to list.
func inlineFlagList(names []string) string {
if len(names) == 0 {
return ""
}
shown := names
var suffix string
if len(names) > inlineFlagListLimit {
shown = names[:inlineFlagListLimit]
suffix = fmt.Sprintf(", … (%d more; see --help)", len(names)-inlineFlagListLimit)
}
parts := make([]string, len(shown))
for i, n := range shown {
parts[i] = "--" + n
}
return strings.Join(parts, ", ") + suffix
}
// ─── enum vocabulary normalization ──────────────────────────────────────
// enumAliases maps habitual values agents import from CSS / Excel / Google
// Sheets onto the value the Lark API actually uses, keyed by the wrong
// value. Applied only when the alias target is in the enum (and the wrong
// value is not), so e.g. "center" still stands for horizontal alignment
// (where it is valid) and only maps to "middle" for vertical alignment.
var enumAliases = map[string]string{
"center": "middle", // CSS vertical-align: center → Lark "middle"
"centre": "center",
"middle": "center", // CSS-style middle → Lark horizontal "center"
}
// canonicalEnumValue returns the enum entry an off-vocabulary value
// unambiguously means — exact case-insensitive match first, then the
// cross-vocabulary alias table. Unlike an edit-distance guess, the result
// is safe to apply on the caller's behalf. Returns "" when the value has
// no unambiguous canonical form in this enum.
func canonicalEnumValue(val string, enum []string) string {
lower := strings.ToLower(val)
for _, allowed := range enum {
if strings.ToLower(allowed) == lower {
return allowed
}
}
if target, ok := enumAliases[lower]; ok {
if slices.Contains(enum, target) {
return target
}
}
return ""
}
// closestEnumValue picks the best "did you mean" candidate for an invalid
// enum value: the unambiguous canonical form first, then edit distance.
// For prose suggestions only — an edit-distance match must never be
// auto-applied. Returns "" when nothing is close.
func closestEnumValue(val string, enum []string) string {
if canon := canonicalEnumValue(val, enum); canon != "" {
return canon
}
if match := suggest.Closest(val, enum, 1); len(match) > 0 {
return match[0]
}
return ""
}
// chainEnumNormalization installs a PreRunE stage (composed onto any
// framework-set PreRunE, which runs first so OnInvoke side effects and the
// --print-schema required-flag relaxation keep their contracts) that
// normalizes the command's flat enum flags before the common runner
// validates them:
//
// - an unambiguous vocabulary mismatch (casing, or a known alias like CSS
// "center" for Lark's vertical "middle") IS the value the caller meant —
// rewrite it in place and proceed instead of failing the call just to
// have the agent retype the canonical spelling;
// - anything else fails here with the allowed list plus a "did you mean"
// hint for edit-distance typos — a guess is never auto-applied.
//
// No-op for commands whose flag defs declare no enums.
func chainEnumNormalization(cmd *cobra.Command) {
defs, _ := loadFlagDefs()
spec, ok := defs[cmd.Name()]
if !ok {
return
}
var enumFlags []flagDef
for _, df := range spec.Flags {
if df.Kind != "system" && len(df.Enum) > 0 && df.Type == "string" {
enumFlags = append(enumFlags, df)
}
}
if len(enumFlags) == 0 {
return
}
prev := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if prev != nil {
if err := prev(c, args); err != nil {
return err
}
}
// --print-schema is pure local introspection; the runner never enum-
// validates that path, so don't start here.
if want, err := c.Flags().GetBool("print-schema"); err == nil && want {
return nil
}
for _, df := range enumFlags {
val, err := c.Flags().GetString(df.Name)
if err != nil || val == "" || slices.Contains(df.Enum, val) {
continue
}
if canon := canonicalEnumValue(val, df.Enum); canon != "" {
c.Flags().Set(df.Name, canon)
continue
}
verr := common.ValidationErrorf("invalid value %q for --%s, allowed: %s",
val, df.Name, strings.Join(df.Enum, ", ")).
WithParam("--" + df.Name)
if match := suggest.Closest(val, df.Enum, 1); len(match) > 0 {
verr = verr.WithHint("did you mean %q?", match[0])
}
return verr
}
return nil
}
}