mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
041bf48e0e
Consolidate the CLI output/error contract so agents can predict behavior from a command's declaration: - --json / --format resolved through a single FormatCapabilities set; explicit --format wins over --json across api, service, shortcut and auth scopes - --format pretty renders as indented JSON via its own Format case, no longer aliased to table - a single root FlagErrorFunc classifies pflag typed errors; the mail and sheets local FlagErrorFunc hooks are removed - cobra argument, required-flag and flag-group errors are typed at their validation stage; the usage-error text markers are removed - root and group unknown commands return structured suggestions - manifest-export marks pure group nodes as non-runnable
832 lines
31 KiB
Go
832 lines
31 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/larksuite/cli/cmd/service"
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/extension/platform"
|
|
"github.com/larksuite/cli/internal/build"
|
|
"github.com/larksuite/cli/internal/cmdmeta"
|
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/deprecation"
|
|
"github.com/larksuite/cli/internal/hook"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/internal/skillscheck"
|
|
"github.com/larksuite/cli/internal/suggest"
|
|
"github.com/larksuite/cli/internal/update"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
)
|
|
|
|
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
|
|
|
AGENT QUICKSTART (driving this as an agent? start here):
|
|
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
|
|
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
|
|
Prefer a +shortcut over the raw API resource when one matches the task.
|
|
Risk: each command's --help shows read | write | high-risk-write;
|
|
high-risk-write needs --yes, only after the user confirms.
|
|
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
|
|
|
|
EXAMPLES (one per command style, in order of preference):
|
|
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
|
|
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
|
|
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
|
|
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
|
|
|
|
// rootUsageTemplate is cobra's default usage template with two root-only
|
|
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
|
|
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
|
|
// footer. Subcommands render the stock template unchanged. The rest is verbatim
|
|
// cobra so the command groups and flags are untouched.
|
|
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
|
|
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
|
{{.CommandPath}} [command]{{end}}{{else}}Usage:
|
|
lark-cli <command> [subcommand] [method] [flags]
|
|
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
|
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
|
|
|
|
Aliases:
|
|
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
|
|
|
Examples:
|
|
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
|
|
|
|
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
|
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
|
|
|
|
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
|
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
|
|
|
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
|
|
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
|
|
|
Flags:
|
|
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
|
|
|
Global Flags:
|
|
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
|
|
|
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
|
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
|
|
|
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
|
|
|
|
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
|
|
`
|
|
|
|
// Execute runs the root command and returns the process exit code.
|
|
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
|
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
|
|
// before they reach a group's RunE, so unknownSubcommandRunE re-derives them
|
|
// from here. It stays nil in unit tests that invoke a RunE directly with
|
|
// explicit args — correct, since those don't exercise the whitelist path.
|
|
var rawInvocationArgs []string
|
|
|
|
func Execute() int {
|
|
rawInvocationArgs = os.Args[1:]
|
|
inv, err := BootstrapInvocationContext(os.Args[1:])
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "Error:", err)
|
|
return 1
|
|
}
|
|
configureFlagCompletions(os.Args)
|
|
|
|
ctx := context.Background()
|
|
f, rootCmd, reg := buildInternal(
|
|
ctx, inv,
|
|
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
|
HideProfile(isSingleAppMode()),
|
|
WithStartupBrand(ResolveStartupBrand(inv.Profile)),
|
|
)
|
|
|
|
// --- Notices (non-blocking) ---
|
|
if !isCompletionCommand(os.Args) {
|
|
setupNotices()
|
|
}
|
|
|
|
runErr := rootCmd.Execute()
|
|
|
|
// Fire Shutdown lifecycle hooks regardless of run outcome.
|
|
// emitShutdown imposes a 2s total deadline and never propagates handler
|
|
// errors (Emit's documented Shutdown contract), so it cannot block exit
|
|
// or alter the user-visible exit code.
|
|
if reg != nil && !isCompletionCommand(os.Args) {
|
|
_ = hook.Emit(ctx, reg, platform.Shutdown, runErr)
|
|
}
|
|
|
|
if runErr != nil {
|
|
return handleRootError(f, runErr)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// setupNotices wires both the binary update notice and the skills
|
|
// staleness notice into output.PendingNotice as a composed function.
|
|
// Each provider populates an independent key under _notice; either
|
|
// or both may be present in any given envelope.
|
|
func setupNotices() {
|
|
// Binary update — synchronous cache check + async refresh
|
|
if info := update.CheckCached(build.Version); info != nil {
|
|
update.SetPending(info)
|
|
}
|
|
ver := build.Version
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
|
|
}
|
|
}()
|
|
update.RefreshCache(ver)
|
|
if update.GetPending() == nil {
|
|
if info := update.CheckCached(ver); info != nil {
|
|
update.SetPending(info)
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Skills check — synchronous, local-only (no network, no goroutine).
|
|
skillscheck.Init(build.Version)
|
|
|
|
// Composed notice provider — emits keys only when each pending is set.
|
|
output.PendingNotice = composePendingNotice
|
|
}
|
|
|
|
// composePendingNotice merges all process-level pending notices (available
|
|
// update, skills/binary drift, deprecated-command alias) into the map surfaced
|
|
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
|
|
// Extracted from Execute so the composition is unit-testable.
|
|
func composePendingNotice() map[string]interface{} {
|
|
notice := map[string]interface{}{}
|
|
if info := update.GetPending(); info != nil {
|
|
notice["update"] = map[string]interface{}{
|
|
"current": info.Current,
|
|
"latest": info.Latest,
|
|
"message": info.Message(),
|
|
"command": "lark-cli update",
|
|
}
|
|
}
|
|
if stale := skillscheck.GetPending(); stale != nil {
|
|
notice["skills"] = map[string]interface{}{
|
|
"current": stale.Current,
|
|
"target": stale.Target,
|
|
"message": stale.Message(),
|
|
"command": "lark-cli update",
|
|
}
|
|
}
|
|
if dep := deprecation.GetPending(); dep != nil {
|
|
entry := map[string]interface{}{
|
|
"command": dep.Command,
|
|
"message": dep.Message(),
|
|
"action": "lark-cli update",
|
|
}
|
|
if dep.Replacement != "" {
|
|
entry["replacement"] = dep.Replacement
|
|
}
|
|
if dep.Skill != "" {
|
|
entry["skill"] = dep.Skill
|
|
}
|
|
notice["deprecated_command"] = entry
|
|
}
|
|
if len(notice) == 0 {
|
|
return nil
|
|
}
|
|
return notice
|
|
}
|
|
|
|
// isCompletionCommand returns true if args indicate a shell completion request.
|
|
// Update notifications and Shutdown lifecycle emits must be suppressed for
|
|
// these to avoid corrupting machine-parseable completion output and to avoid
|
|
// firing plugin Shutdown handlers on every Tab keystroke.
|
|
//
|
|
// Cobra dispatches BOTH "__complete" and its alias "__completeNoDesc" through
|
|
// the same hidden subcommand (see cobra/completions.go ShellCompRequestCmd /
|
|
// ShellCompNoDescRequestCmd). Check both, otherwise bash/zsh completion
|
|
// (which often uses NoDesc) silently bypasses the gate.
|
|
func isCompletionCommand(args []string) bool {
|
|
for _, arg := range args {
|
|
if arg == "completion" || arg == "__complete" || arg == "__completeNoDesc" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// configureFlagCompletions enables cmdutil.RegisterFlagCompletion only when
|
|
// the invocation will actually serve a __complete request.
|
|
func configureFlagCompletions(args []string) {
|
|
cmdutil.SetFlagCompletionsEnabled(isCompletionCommand(args))
|
|
}
|
|
|
|
// handleRootError dispatches a command error to the appropriate handler
|
|
// and returns the process exit code.
|
|
//
|
|
// Dispatch order:
|
|
// 1. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
|
|
// *errs.SecurityPolicyError, *errs.AuthenticationError, *errs.ConfigError):
|
|
// render via the typed envelope writer, which lifts extension fields
|
|
// (missing_scopes, console_url, challenge_url, ...) to the top level.
|
|
// Routed by errs.CategoryOf via ExitCodeOf. Auth and config errors are
|
|
// constructed typed at their origin (internal/auth, internal/core), so the
|
|
// dispatcher no longer promotes any legacy shape here.
|
|
// 2. PartialFailure / BareError signals: the result envelope is already on
|
|
// stdout; honor the exit code and write nothing to stderr.
|
|
// 3. Any untyped error that reaches this boundary is an internal fault.
|
|
// Cobra argument, required-flag and flag-group errors are typed at their
|
|
// execution stages by installCobraValidationGuards; flag parse errors are
|
|
// typed by the root FlagErrorFunc.
|
|
func handleRootError(f *cmdutil.Factory, err error) int {
|
|
errOut := f.IOStreams.ErrOut
|
|
|
|
// When the typed error is a need_user_authorization signal, fold in the
|
|
// current command's declared scopes as a Hint so the user/AI sees the
|
|
// concrete scope(s) to re-auth with. The hint is computed on the fly from
|
|
// local shortcut/service metadata — it never depends on server state.
|
|
if !errs.IsRaw(err) {
|
|
applyNeedAuthorizationHint(f, err)
|
|
}
|
|
|
|
// Staged dispatch: capture the typed exit code BEFORE attempting the
|
|
// envelope write. WriteTypedErrorEnvelope is best-effort on the wire
|
|
// (partial-write still returns true) so the exit code we read here is
|
|
// preserved even if stderr is torn — torn stderr must not downgrade
|
|
// typed exits 3/4/6/10 to the plain "Error:" path with exit 1.
|
|
// WriteTypedErrorEnvelope still returns false when err carries no
|
|
// Problem; in that case we fall through to the signal / plain-text paths.
|
|
typedExit := output.ExitCodeOf(err)
|
|
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
|
|
return typedExit
|
|
}
|
|
|
|
// Partial-failure (batch / multi-status): the ok:false result envelope is
|
|
// already on stdout; set the exit code and write nothing to stderr.
|
|
var pfErr *output.PartialFailureError
|
|
if errors.As(err, &pfErr) {
|
|
return pfErr.Code
|
|
}
|
|
|
|
// Silent-exit signal (e.g. `auth check` predicate, or `update --json`):
|
|
// stdout already carries the result; honor the requested exit code and
|
|
// write nothing to stderr.
|
|
var bareErr *output.BareError
|
|
if errors.As(err, &bareErr) {
|
|
return bareErr.Code
|
|
}
|
|
|
|
// Every user-input stage is typed before execution. A bare error here has
|
|
// crossed that boundary unexpectedly and must remain visible as an internal
|
|
// fault instead of being guessed from English message fragments.
|
|
fallback := errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
|
|
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
|
|
return output.ExitCodeOf(fallback)
|
|
}
|
|
|
|
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
|
|
// group commands (no Run/RunE) with an unknown_subcommand error.
|
|
//
|
|
// IMPORTANT: every command modified here is also tagged with
|
|
// cmdpolicy.AnnotationPureGroup so the user-layer policy engine
|
|
// continues to treat the command as a pure parent group. Without the
|
|
// tag, the RunE injection here would flip Runnable()=true and a user
|
|
// rule like `max_risk: read` would deny every `<group> --help` call
|
|
// with reason_code=risk_not_annotated.
|
|
func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
|
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
|
|
// Cobra's legacy Args fallback rejects an unknown top-level token before
|
|
// RunE can produce ranked suggestions. Explicitly accepting positional
|
|
// tokens lets every pure group, including root, reach the shared guard.
|
|
cmd.Args = cobra.ArbitraryArgs
|
|
cmd.RunE = unknownSubcommandRunE
|
|
// Route an unknown subcommand to unknownSubcommandRunE even when flags
|
|
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
|
|
// consumes no flags itself, so unknown flags belong to the (missing)
|
|
// subcommand; whitelisting them here prevents cobra from erroring on the
|
|
// flag first and printing usage instead of our structured suggestion.
|
|
cmd.FParseErrWhitelist.UnknownFlags = true
|
|
if cmd.Annotations == nil {
|
|
cmd.Annotations = map[string]string{}
|
|
}
|
|
cmd.Annotations[cmdpolicy.AnnotationPureGroup] = "true"
|
|
}
|
|
for _, c := range cmd.Commands() {
|
|
installUnknownSubcommandGuard(c)
|
|
}
|
|
}
|
|
|
|
// installCobraValidationGuards types errors at the stage where Cobra knows
|
|
// they are user input: positional argument validation, required flags and flag
|
|
// groups. This removes the need for final-boundary message matching.
|
|
func installCobraValidationGuards(cmd *cobra.Command) {
|
|
if cmd == nil {
|
|
return
|
|
}
|
|
if validateArgs := cmd.Args; validateArgs != nil {
|
|
cmd.Args = func(c *cobra.Command, args []string) error {
|
|
err := validateArgs(c, args)
|
|
if err == nil || errs.IsTyped(err) {
|
|
return err
|
|
}
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
|
|
}
|
|
}
|
|
|
|
previousPreRunE := cmd.PreRunE
|
|
previousPreRun := cmd.PreRun
|
|
cmd.PreRunE = func(c *cobra.Command, args []string) error {
|
|
if previousPreRunE != nil {
|
|
if err := previousPreRunE(c, args); err != nil {
|
|
return err
|
|
}
|
|
} else if previousPreRun != nil {
|
|
previousPreRun(c, args)
|
|
}
|
|
if err := c.ValidateRequiredFlags(); err != nil {
|
|
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
|
|
c.Flags().VisitAll(func(flag *pflag.Flag) {
|
|
if flag.Changed || len(flag.Annotations[cobra.BashCompOneRequiredFlag]) == 0 {
|
|
return
|
|
}
|
|
validationErr.WithParams(errs.InvalidParam{Name: "--" + flag.Name, Reason: "required flag is missing"})
|
|
})
|
|
return validationErr.WithHint("run `%s --help` to see required flags", c.CommandPath())
|
|
}
|
|
if err := c.ValidateFlagGroups(); err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).
|
|
WithParams(invalidFlagGroupParams(err.Error())...).
|
|
WithHint("run `%s --help` to see valid flag combinations", c.CommandPath()).
|
|
WithCause(err)
|
|
}
|
|
return nil
|
|
}
|
|
cmd.PreRun = nil
|
|
|
|
for _, child := range cmd.Commands() {
|
|
installCobraValidationGuards(child)
|
|
}
|
|
}
|
|
|
|
type flagGroupConstraint int
|
|
|
|
const (
|
|
flagGroupRequiredTogether flagGroupConstraint = iota
|
|
flagGroupOneRequired
|
|
flagGroupMutuallyExclusive
|
|
)
|
|
|
|
// invalidFlagGroupParams extracts the offending flag group from Cobra's
|
|
// flag-group validation error. The message always names the group as
|
|
// "[flag-a flag-b ...]" and its wording identifies the constraint, so both are
|
|
// read straight from the message instead of re-deriving Cobra's private group
|
|
// state. Pinned to cobra v1.10.2's message format (see go.mod).
|
|
func invalidFlagGroupParams(message string) []errs.InvalidParam {
|
|
names := flagGroupNamesFromMessage(message)
|
|
if len(names) == 0 {
|
|
return nil
|
|
}
|
|
return buildFlagGroupParams(names, flagGroupConstraintFromMessage(message))
|
|
}
|
|
|
|
func buildFlagGroupParams(names []string, constraint flagGroupConstraint) []errs.InvalidParam {
|
|
flagNames := make([]string, 0, len(names))
|
|
for _, name := range names {
|
|
name = strings.TrimLeft(name, "-")
|
|
if name != "" {
|
|
flagNames = append(flagNames, "--"+name)
|
|
}
|
|
}
|
|
if len(flagNames) == 0 {
|
|
return nil
|
|
}
|
|
group := "[" + strings.Join(flagNames, " ") + "]"
|
|
reason := "invalid flag combination in " + group
|
|
switch constraint {
|
|
case flagGroupRequiredTogether:
|
|
reason = "all of " + group + " required together"
|
|
case flagGroupOneRequired:
|
|
reason = "one of " + group + " required"
|
|
case flagGroupMutuallyExclusive:
|
|
reason = "only one of " + group + " allowed"
|
|
}
|
|
params := make([]errs.InvalidParam, 0, len(flagNames))
|
|
for _, name := range flagNames {
|
|
params = append(params, errs.InvalidParam{Name: name, Reason: reason})
|
|
}
|
|
return params
|
|
}
|
|
|
|
func flagGroupNamesFromMessage(message string) []string {
|
|
start := strings.IndexByte(message, '[')
|
|
if start < 0 {
|
|
return nil
|
|
}
|
|
end := strings.IndexByte(message[start+1:], ']')
|
|
if end < 0 {
|
|
return nil
|
|
}
|
|
return strings.Fields(message[start+1 : start+1+end])
|
|
}
|
|
|
|
func flagGroupConstraintFromMessage(message string) flagGroupConstraint {
|
|
switch {
|
|
case strings.Contains(message, "at least one of the flags"):
|
|
return flagGroupOneRequired
|
|
case strings.Contains(message, "must all be set"):
|
|
return flagGroupRequiredTogether
|
|
case strings.Contains(message, "none of the others can be"):
|
|
return flagGroupMutuallyExclusive
|
|
default:
|
|
return flagGroupConstraint(-1)
|
|
}
|
|
}
|
|
|
|
// unknownSubcommandRunE replaces cobra's silent help fallback on group commands
|
|
// with a typed *errs.ValidationError: a flag that belongs to a missing
|
|
// subcommand, a misplaced subcommand-only flag, or an unknown subcommand name
|
|
// each fail structured (exit 2) instead of degrading to help + exit 0.
|
|
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
|
if len(args) == 0 {
|
|
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
|
|
// like the global --profile, legitimately prints help. But a flag that
|
|
// belongs to a (missing) subcommand is a user error: the guard's
|
|
// FParseErrWhitelist swallows such flags and leaves args empty, so without
|
|
// the checks below they would silently fall through to help + exit 0 —
|
|
// letting an agent mistake a malformed call (`im --format json`,
|
|
// `sheets --badflag`) for success. Recover the swallowed tokens from the
|
|
// raw invocation and fail structured instead.
|
|
flags := flagTokensInArgs(rawInvocationArgs)
|
|
if len(flags) == 0 {
|
|
return cmd.Help()
|
|
}
|
|
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
|
|
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()).
|
|
WithHint("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath())
|
|
for _, flag := range unknown {
|
|
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "unknown flag before a subcommand"})
|
|
}
|
|
return verr
|
|
}
|
|
// The remaining flags are all defined somewhere in the tree. Those valid
|
|
// on the group itself or inherited (e.g. the global --profile) do not
|
|
// require a subcommand, so a bare group carrying only those still prints
|
|
// help. Anything left belongs to a subcommand that was omitted
|
|
// (e.g. `im --format json`): distinct from unknown_flag — the flags are
|
|
// real, the subcommand is what's missing.
|
|
misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs)
|
|
if len(misplaced) == 0 {
|
|
return cmd.Help()
|
|
}
|
|
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")).
|
|
WithHint("run `%s --help` to list subcommands and their flags", cmd.CommandPath())
|
|
for _, flag := range misplaced {
|
|
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "flag belongs to a subcommand, not the group"})
|
|
}
|
|
return verr
|
|
}
|
|
unknown := args[0]
|
|
available, deprecated := availableSubcommandNames(cmd)
|
|
// Rank suggestions across both current and deprecated names so a mistyped
|
|
// legacy command (e.g. +raed → +read) still resolves; the alias stays
|
|
// runnable and self-flags via the _notice on execution.
|
|
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
|
|
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
|
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
|
if len(suggestions) > 0 {
|
|
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
|
|
strings.Join(suggestions, ", "), cmd.CommandPath())
|
|
}
|
|
// Record the offending subcommand and its ranked candidates as a param with
|
|
// machine-readable Suggestions so an agent can retry without parsing the
|
|
// hint; the hint carries the same candidates as prose.
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
|
WithParams(errs.InvalidParam{Name: unknown, Reason: "unknown subcommand", Suggestions: suggestions}).
|
|
WithHint("%s", hint)
|
|
}
|
|
|
|
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
|
|
// rawArgs, stopping at the "--" positional terminator. Whether a flag is
|
|
// defined is not considered (see unknownFlagTokens for that). A pure group
|
|
// with any flag token but no subcommand is a user error — a pure group
|
|
// consumes no flags of its own, so the flag must belong to a subcommand — so
|
|
// the caller fails structured instead of falling through to help.
|
|
func flagTokensInArgs(rawArgs []string) []string {
|
|
var toks []string
|
|
for _, a := range rawArgs {
|
|
if a == "--" {
|
|
break // everything after -- is positional
|
|
}
|
|
if len(a) < 2 || a[0] != '-' {
|
|
continue
|
|
}
|
|
toks = append(toks, a)
|
|
}
|
|
return toks
|
|
}
|
|
|
|
// unknownFlagTokens returns the flag tokens in rawArgs that cmd does not define
|
|
// (on itself, inherited, or any direct subcommand). installUnknownSubcommandGuard
|
|
// whitelists unknown flags on pure groups so a mistyped subcommand still reaches
|
|
// the suggestion path; the side effect is that flags before a subcommand are
|
|
// swallowed. This recovers the genuinely-unknown ones so the caller can name
|
|
// them in a "did you mean" envelope.
|
|
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
|
var unknown []string
|
|
for _, a := range flagTokensInArgs(rawArgs) {
|
|
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
|
if name != "" && !flagDefinedInTree(cmd, name) {
|
|
unknown = append(unknown, a)
|
|
}
|
|
}
|
|
return unknown
|
|
}
|
|
|
|
// flagKnownOnGroup reports whether name is a flag defined on cmd itself or
|
|
// inherited (a global persistent flag like --profile) — i.e. valid on the bare
|
|
// group and therefore not requiring a subcommand.
|
|
func flagKnownOnGroup(cmd *cobra.Command, name string) bool {
|
|
short := len(name) == 1
|
|
lookup := func(fs *pflag.FlagSet) bool {
|
|
if short {
|
|
return fs.ShorthandLookup(name) != nil
|
|
}
|
|
return fs.Lookup(name) != nil
|
|
}
|
|
return lookup(cmd.Flags()) || lookup(cmd.InheritedFlags())
|
|
}
|
|
|
|
// subcommandOnlyFlagTokens returns the flag tokens in rawArgs that are valid on
|
|
// a subcommand of cmd but not on cmd itself/inherited — flags supplied while
|
|
// omitting the subcommand they belong to (`im --format json`). Global flags
|
|
// valid on the bare group (e.g. --profile) are excluded so
|
|
// `lark-cli --profile p im` still prints help rather than erroring.
|
|
func subcommandOnlyFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
|
var misplaced []string
|
|
for _, a := range flagTokensInArgs(rawArgs) {
|
|
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
|
if name == "" || flagKnownOnGroup(cmd, name) {
|
|
continue
|
|
}
|
|
if flagDefinedInTree(cmd, name) {
|
|
misplaced = append(misplaced, a)
|
|
}
|
|
}
|
|
return misplaced
|
|
}
|
|
|
|
// flagDefinedInTree reports whether name is defined on cmd, its inherited
|
|
// (persistent) flags, or any direct subcommand. The subcommand case covers a
|
|
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
|
|
// --format is injected on every leaf shortcut, not on the group — so only a
|
|
// genuinely unknown flag like `sheets --badflag` is reported.
|
|
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
|
|
short := len(name) == 1
|
|
known := func(c *cobra.Command, inherited bool) bool {
|
|
fs := c.Flags()
|
|
if inherited {
|
|
fs = c.InheritedFlags()
|
|
}
|
|
if short {
|
|
return fs.ShorthandLookup(name) != nil
|
|
}
|
|
return fs.Lookup(name) != nil
|
|
}
|
|
if known(cmd, false) || known(cmd, true) {
|
|
return true
|
|
}
|
|
for _, c := range cmd.Commands() {
|
|
if known(c, false) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// availableSubcommandNames returns the invokable subcommand names of cmd, split
|
|
// into current commands and backward-compatibility aliases (those tagged into
|
|
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
|
|
// sorted; hidden commands plus help/completion are omitted.
|
|
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
|
|
for _, c := range cmd.Commands() {
|
|
if c.Hidden || !c.IsAvailableCommand() {
|
|
continue
|
|
}
|
|
name := c.Name()
|
|
if name == "help" || name == "completion" {
|
|
continue
|
|
}
|
|
if cmdutil.IsDeprecatedCommand(c) {
|
|
deprecated = append(deprecated, name)
|
|
} else {
|
|
available = append(available, name)
|
|
}
|
|
}
|
|
sort.Strings(available)
|
|
sort.Strings(deprecated)
|
|
return available, deprecated
|
|
}
|
|
|
|
// Root command help groups, so an agent sees content domains, agent tooling, and
|
|
// CLI management as distinct blocks instead of one flat alphabetical dump.
|
|
const (
|
|
groupDomains = "lark-domains"
|
|
groupTooling = "agent-tooling"
|
|
groupManagement = "cli-management"
|
|
)
|
|
|
|
// groupRootCommands classifies root's direct children into the help groups,
|
|
// called once after all commands are registered. Unclassified commands fall to
|
|
// cobra's "Additional Commands" section.
|
|
func groupRootCommands(root *cobra.Command) {
|
|
root.AddGroup(
|
|
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
|
|
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
|
|
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
|
|
)
|
|
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
|
|
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
|
|
for _, c := range root.Commands() {
|
|
if c.GroupID != "" {
|
|
continue
|
|
}
|
|
switch {
|
|
case tooling[c.Name()]:
|
|
c.GroupID = groupTooling
|
|
case management[c.Name()]:
|
|
c.GroupID = groupManagement
|
|
case isLarkDomain(c):
|
|
c.GroupID = groupDomains
|
|
}
|
|
}
|
|
}
|
|
|
|
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
|
|
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
|
|
func isLarkDomain(c *cobra.Command) bool {
|
|
if src, _ := cmdmeta.SourceOf(c); src == cmdmeta.SourceService {
|
|
return true
|
|
}
|
|
return cmdmeta.Domain(c) != ""
|
|
}
|
|
|
|
// flagDidYouMean is the single FlagErrorFunc inherited by all commands. It
|
|
// classifies pflag's typed parse errors and emits one stable validation shape.
|
|
func flagDidYouMean(c *cobra.Command, ferr error) error {
|
|
if ferr == nil {
|
|
return nil
|
|
}
|
|
var notExist *pflag.NotExistError
|
|
if !errors.As(ferr, ¬Exist) {
|
|
return typedFlagParseError(c, ferr)
|
|
}
|
|
|
|
name := notExist.GetSpecifiedName()
|
|
rawName := "--" + name
|
|
if notExist.GetSpecifiedShortnames() != "" {
|
|
rawName = "-" + name
|
|
}
|
|
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 len(suggestions) > 0 {
|
|
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
|
|
strings.Join(suggestions, ", "), c.CommandPath())
|
|
}
|
|
if cmdmeta.Domain(c) == "sheets" {
|
|
if list := inlineVisibleFlags(valid); list != "" {
|
|
if len(suggestions) > 0 {
|
|
hint = fmt.Sprintf("did you mean %s? valid flags: %s", strings.Join(suggestions, ", "), list)
|
|
} else {
|
|
hint = "valid flags: " + list
|
|
}
|
|
}
|
|
}
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"unknown flag %q for %q", rawName, c.CommandPath()).
|
|
WithParams(errs.InvalidParam{Name: rawName, Reason: "unknown flag", Suggestions: suggestions}).
|
|
WithHint("%s", hint).
|
|
WithCause(ferr)
|
|
}
|
|
|
|
func typedFlagParseError(c *cobra.Command, ferr error) error {
|
|
param := ""
|
|
reason := "flag parse error"
|
|
var valueRequired *pflag.ValueRequiredError
|
|
var invalidValue *pflag.InvalidValueError
|
|
var invalidSyntax *pflag.InvalidSyntaxError
|
|
switch {
|
|
case errors.As(ferr, &valueRequired):
|
|
param = "--" + valueRequired.GetSpecifiedName()
|
|
if valueRequired.GetSpecifiedShortnames() != "" {
|
|
param = "-" + valueRequired.GetSpecifiedName()
|
|
}
|
|
reason = "flag value is required"
|
|
case errors.As(ferr, &invalidValue):
|
|
if invalidValue.GetFlag() != nil {
|
|
param = "--" + invalidValue.GetFlag().Name
|
|
}
|
|
reason = "invalid flag value"
|
|
case errors.As(ferr, &invalidSyntax):
|
|
param = invalidSyntax.GetSpecifiedFlag()
|
|
reason = "invalid flag syntax"
|
|
}
|
|
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
|
|
WithHint("run `%s --help` for valid flags", c.CommandPath()).
|
|
WithCause(ferr)
|
|
if param != "" {
|
|
validationErr.WithParams(errs.InvalidParam{Name: param, Reason: reason})
|
|
}
|
|
return validationErr
|
|
}
|
|
|
|
func inlineVisibleFlags(names []string) string {
|
|
const limit = 25
|
|
if len(names) == 0 {
|
|
return ""
|
|
}
|
|
shown := names
|
|
suffix := ""
|
|
if len(shown) > limit {
|
|
shown = shown[:limit]
|
|
suffix = fmt.Sprintf(", ... (%d more; see --help)", len(names)-limit)
|
|
}
|
|
flags := make([]string, len(shown))
|
|
for i, name := range shown {
|
|
flags[i] = "--" + name
|
|
}
|
|
return strings.Join(flags, ", ") + suffix
|
|
}
|
|
|
|
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
|
|
// the valid_flags detail).
|
|
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
|
|
}
|
|
|
|
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
|
// when a command has tips set via cmdutil.SetTips. It also force-shows global
|
|
// flags that are normally hidden in single-app mode (currently --profile)
|
|
// when rendering the root command's own help, so users discovering the CLI
|
|
// still see them at `lark-cli --help`.
|
|
func installTipsHelpFunc(root *cobra.Command) {
|
|
defaultHelp := root.HelpFunc()
|
|
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
|
if cmd == root {
|
|
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden {
|
|
f.Hidden = false
|
|
defer func() { f.Hidden = true }()
|
|
}
|
|
}
|
|
// Domain and method commands compose their agent guidance into Long lazily
|
|
// here (shortcuts attach after service registration); both skip the generic
|
|
// bottom-of-help append below.
|
|
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
|
|
defaultHelp(cmd, args)
|
|
return
|
|
}
|
|
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
|
|
defaultHelp(cmd, args)
|
|
return
|
|
}
|
|
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
|
|
defaultHelp(cmd, args)
|
|
return
|
|
}
|
|
defaultHelp(cmd, args)
|
|
out := cmd.OutOrStdout()
|
|
if level, ok := cmdutil.GetRisk(cmd); ok {
|
|
fmt.Fprintln(out)
|
|
fmt.Fprintln(out, "Risk:", level)
|
|
}
|
|
tips := cmdutil.GetTips(cmd)
|
|
if len(tips) == 0 {
|
|
return
|
|
}
|
|
fmt.Fprintln(out)
|
|
fmt.Fprintln(out, "Tips:")
|
|
for _, tip := range tips {
|
|
fmt.Fprintf(out, " • %s\n", tip)
|
|
}
|
|
})
|
|
}
|