Compare commits

..

3 Commits

Author SHA1 Message Date
huangmengxuan
0ff9c84cd8 feat(skill): add relative-path-only rule to lark-shared security section
lark-cli rejects absolute paths for --file, --output, --output-dir,
and @file with 'unsafe file path'. Document this in lark-shared so
agents know to use cwd-relative paths or stdin for data input.

Change-Id: I50cf801c2c5d0e3cbb98a76e1752d410518c8636
2026-06-03 13:59:06 +08:00
huangmengxuan
175c9f6ffc feat(skill): add CRITICAL instruction to lark-task, lark-contact, lark-slides
Same enforcement as the previous commit — require reading reference
docs (or -h) before calling shortcuts. These three skills use
non-standard section headers but still have shortcut tables with
reference links.

Change-Id: I5170cc763c15e3030c4117a36af36c9f1e94501e
2026-06-03 13:59:06 +08:00
huangmengxuan
575bcc407b feat(skill): add CRITICAL instruction to enforce reference reading before shortcut execution
Evaluation data shows AI call failure rate <1% when reference docs are
read vs ~29% when not. Add a CRITICAL line to the Shortcuts section of
14 SKILL.md files and the skill template, requiring agents to read the
linked reference doc (or run -h for commands without one) before
invoking any shortcut.

Change-Id: Ia4204518eb43a9f6c8295b95633ee5d9cf2f5352
2026-06-03 13:59:06 +08:00
205 changed files with 2826 additions and 38609 deletions

View File

@@ -57,14 +57,6 @@ linters:
- path: internal/vfs/
linters:
- forbidigo
# internal/gen build-time generators (standalone `package main` run via
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
# impossible here: a structured error return needs os.Exit (also banned),
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
- path: shortcuts/.*/internal/gen/
linters:
- forbidigo
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer.
- path-except: shortcuts/

View File

@@ -2,26 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.47] - 2026-06-03
### Features
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
- **base**: Add base block shortcuts (#1044)
- **im**: Complete card message format (#1198)
- **im**: Improve markdown guidance for messages (#1237)
- **vc**: Forward invite call-id on meeting join (#1243)
- **drive**: Emit typed error envelopes across the drive domain (#1205)
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
- **wiki**: Support `appid` member type (#1235)
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
- **config**: Validate credentials after `config init` (#1151)
### Bug Fixes
- **skills**: Recover empty fallback for skills to update (#1233)
## [v1.0.46] - 2026-06-02
### Features
@@ -1009,7 +989,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44

View File

@@ -117,13 +117,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
// inherited by every subcommand, turning unknown-flag errors into a
// structured "did you mean" envelope.
rootCmd.SilenceUsage = true
rootCmd.SetFlagErrorFunc(flagDidYouMean)
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {

View File

@@ -10,7 +10,6 @@ import (
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/suggest"
)
const maxSuggestions = 3
@@ -29,7 +28,7 @@ func suggestEventKeys(input string) []string {
hits = append(hits, match{def.Key, 0})
continue
}
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
if d := levenshtein(input, def.Key); d <= threshold {
hits = append(hits, match{def.Key, d})
}
}
@@ -70,3 +69,34 @@ func unknownEventKeyErr(key string) error {
"Run 'lark-cli event list' to see available keys.",
)
}
// levenshtein computes classic edit distance (two-row DP).
func levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}

View File

@@ -10,6 +10,27 @@ import (
_ "github.com/larksuite/cli/events"
)
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "", 1},
{"", "abc", 3},
{"kitten", "kitten", 0},
{"kitten", "sitten", 1},
{"kitten", "sitting", 3},
{"飞书", "飞书", 0},
{"飞书", "飞s", 1},
}
for _, tc := range cases {
if got := levenshtein(tc.a, tc.b); got != tc.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestSuggestEventKeys(t *testing.T) {
cases := []struct {
name string

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"slices"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
func TestUnknownFlagName(t *testing.T) {
cases := []struct {
in string
name string
ok bool
}{
{"unknown flag: --query", "query", true},
{"unknown flag: --with-styles", "with-styles", true},
{"unknown shorthand flag: 'z' in -z", "", false},
{"flag needs an argument: --find", "", false},
{`invalid argument "x" for "--count"`, "", false},
}
for _, c := range cases {
name, ok := unknownFlagName(errors.New(c.in))
if name != c.name || ok != c.ok {
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
}
}
}
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().String("find", "", "")
c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
}
detail, _ := exitErr.Detail.Detail.(map[string]any)
valid, _ := detail["valid_flags"].([]string)
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
t.Errorf("valid_flags should list find & range, got %v", valid)
}
}
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Type != "flag_error" {
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
}
}

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/deprecation"
)
// composePendingNotice must surface a deprecated-command alias under the
// "deprecated_command" key, with the migration target and a skill-update hint,
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
// without ever reading --help.
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(&deprecation.Notice{
Command: "+read",
Replacement: "+cells-get",
Skill: "lark-sheets",
})
got := composePendingNotice()
if got == nil {
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
}
entry, ok := got["deprecated_command"].(map[string]interface{})
if !ok {
t.Fatalf("missing deprecated_command key: %#v", got)
}
if entry["command"] != "+read" {
t.Errorf("command = %v, want +read", entry["command"])
}
if entry["replacement"] != "+cells-get" {
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
}
if entry["skill"] != "lark-sheets" {
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
}
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
t.Errorf("message missing skill-update hint: %q", msg)
}
}
// With nothing pending, the provider returns nil so no "_notice" field is
// emitted on a clean run.
func TestComposePendingNoticeEmpty(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
if got := composePendingNotice(); got != nil {
// update/skills pending are process-global; only assert the absence of
// our own key to stay robust against unrelated pending state.
if _, ok := got["deprecated_command"]; ok {
t.Fatalf("deprecated_command present after clear: %#v", got)
}
}
}

View File

@@ -18,17 +18,14 @@ import (
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"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.
@@ -72,15 +69,7 @@ COMMUNITY:
More help: lark-cli <command> --help`
// 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)
@@ -144,49 +133,29 @@ func setupNotices() {
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",
output.PendingNotice = func() 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 len(notice) == 0 {
return nil
}
return notice
}
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.
@@ -291,19 +260,6 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return exitErr.Code
}
// A backward-compat alias records its deprecation notice in PreRunE, which
// runs before cobra's required-flag validation — but a missing required flag
// fails before RunE and lands here, where the bare "Error:" line would drop
// the notice. When a deprecation is pending, route through the structured
// envelope so the migration hint still reaches the caller; all other errors
// keep the existing plain output.
if deprecation.GetPending() != nil {
output.WriteErrorEnvelope(errOut, &output.ExitError{
Code: 1,
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
}, string(f.ResolvedIdentity))
return 1
}
fmt.Fprintln(errOut, "Error:", err)
return 1
}
@@ -345,12 +301,6 @@ func asExitError(err error) *output.ExitError {
func installUnknownSubcommandGuard(cmd *cobra.Command) {
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
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{}
}
@@ -370,89 +320,14 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
// they have moved to the typed surface.
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 {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
// Keep the same detail keys as flagDidYouMean's unknown_flag
// so a consumer keyed on Type can read a stable shape. The
// subcommand isn't resolved here, so suggestions/valid_flags
// have no meaningful universe to draw from — emit empty
// rather than the group's own (misleading) flags. unknown is
// the back-compat singular field; unknown_flags carries the
// full list when more than one flag was supplied.
"unknown": strings.Join(unknown, ", "),
"unknown_flags": unknown,
"command_path": cmd.CommandPath(),
"suggestions": []string{},
"valid_flags": []string{},
},
},
}
}
// 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()
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "missing_subcommand",
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
"command_path": cmd.CommandPath(),
"flags": misplaced,
"suggestions": []string{},
},
},
}
return cmd.Help()
}
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)
available := availableSubcommandNames(cmd)
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())
}
detail := map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available,
}
// Only services with backward-compat aliases (currently sheets) carry a
// deprecated bucket; omit the key elsewhere so every other service's
// envelope is unchanged.
if len(deprecated) > 0 {
detail["deprecated"] = deprecated
if len(available) > 0 {
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
}
return &output.ExitError{
Code: output.ExitValidation,
@@ -460,114 +335,17 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
Type: "unknown_subcommand",
Message: msg,
Hint: hint,
Detail: detail,
Detail: map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"available": available,
},
},
}
}
// 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) {
func availableSubcommandNames(cmd *cobra.Command) []string {
subs := make([]string, 0, len(cmd.Commands()))
for _, c := range cmd.Commands() {
if c.Hidden || !c.IsAvailableCommand() {
continue
@@ -576,95 +354,10 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
if name == "help" || name == "completion" {
continue
}
if cmdutil.IsDeprecatedCommand(c) {
deprecated = append(deprecated, name)
} else {
available = append(available, name)
}
subs = append(subs, name)
}
sort.Strings(available)
sort.Strings(deprecated)
return available, deprecated
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
// --find, where edit distance alone finds nothing). Other flag errors stay
// structured but generic.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "flag_error",
Message: ferr.Error(),
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
},
}
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if len(suggestions) > 0 {
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
Hint: hint,
Detail: map[string]any{
"unknown": "--" + name,
"command_path": c.CommandPath(),
"suggestions": suggestions,
"valid_flags": valid,
},
},
}
}
// unknownFlagName 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 the caller keeps
// those structured but generic — hallucinated flags are essentially always long.
//
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
// silently fails and unknown flags degrade to a generic flag_error — re-verify
// this prefix when bumping cobra.
func unknownFlagName(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 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
sort.Strings(subs)
return subs
}
// installTipsHelpFunc wraps the default help function to append a TIPS section

View File

@@ -21,7 +21,6 @@ import (
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
)
@@ -269,54 +268,6 @@ func (f *failingWriter) Write(p []byte) (int, error) {
return len(p), nil
}
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
// backward-compat alias that fails on a cobra-level required flag (which
// short-circuits before RunE) still routes through the structured envelope,
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
// switches to WriteErrorEnvelope when a deprecation is pending — so the
// migration notice is no longer dropped on the plain "Error:" line.
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
deprecation.SetPending(&deprecation.Notice{
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
})
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
// nor an *output.ExitError, so it reaches the legacy fallback.
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out)
}
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
}
}
// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
// fix does not reshape every unrecognized cobra error.
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
if !strings.HasPrefix(errOut.String(), "Error:") {
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
}
}
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
// stderr write fails mid-envelope, handleRootError still returns the typed
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the

View File

@@ -11,7 +11,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -73,149 +72,6 @@ func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
}
}
func TestUnknownFlagTokens(t *testing.T) {
_, drive, _ := newGroupTree()
// Give a subcommand a flag so a misplaced-but-known flag (the user omitted
// the subcommand) is distinguished from a genuinely unknown one.
for _, c := range drive.Commands() {
if c.Name() == "+search" {
c.Flags().String("query", "", "")
}
}
cases := []struct {
name string
rawArgs []string
want []string
}{
{"genuinely unknown long flag", []string{"drive", "--badflag"}, []string{"--badflag"}},
{"flag known on a subcommand (misplaced)", []string{"drive", "--query", "x"}, nil},
{"no flags at all", []string{"drive"}, nil},
{"tokens after -- are positional", []string{"drive", "--", "--badflag"}, nil},
{"unknown shorthand", []string{"drive", "-Z"}, []string{"-Z"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := unknownFlagTokens(drive, tc.rawArgs)
if len(got) != len(tc.want) {
t.Fatalf("unknownFlagTokens(%v) = %v, want %v", tc.rawArgs, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("token[%d] = %q, want %q", i, got[i], tc.want[i])
}
}
})
}
}
func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
// Simulate `lark-cli drive --badflag`: the UnknownFlags whitelist swallows
// --badflag, so RunE sees no args; the guard must recover it from
// rawInvocationArgs and fail structured rather than print help + exit 0.
rawInvocationArgs = []string{"drive", "--badflag"}
t.Cleanup(func() { rawInvocationArgs = nil })
err := drive.RunE(drive, nil)
if err == nil {
t.Fatal("expected a structured unknown_flag error, got nil (help fallthrough)")
}
if !strings.Contains(err.Error(), "unknown flag") {
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
}
// The detail must stay schema-compatible with flagDidYouMean's unknown_flag
// (same Type → same keys), so a consumer keyed on Type reads a stable shape.
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError with Detail, got %T", err)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail)
}
if detail["unknown"] != "--badflag" {
t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"])
}
if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" {
t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"])
}
for _, key := range []string{"suggestions", "valid_flags"} {
if _, present := detail[key]; !present {
t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key)
}
}
}
func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing.T) {
_, drive, _ := newGroupTree()
// --query is defined on the +search subcommand, so it is a *valid* flag that
// was placed before the (omitted) subcommand. Unlike an unknown flag, this
// must still fail structured (missing_subcommand) rather than fall through to
// help + exit 0 — `drive --query x` is a malformed call, not a help request.
for _, c := range drive.Commands() {
if c.Name() == "+search" {
c.Flags().String("query", "", "")
}
}
installUnknownSubcommandGuard(drive.Root())
rawInvocationArgs = []string{"drive", "--query", "x"}
t.Cleanup(func() { rawInvocationArgs = nil })
err := drive.RunE(drive, nil)
if err == nil {
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
}
if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" {
t.Errorf("detail.flags = %v, want [--query]", detail["flags"])
}
if detail["command_path"] != "lark-cli drive" {
t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"])
}
}
// A bare group carrying only a group-valid global flag (e.g. the inherited
// --profile) is not missing a subcommand — those flags do not belong to a
// subcommand — so it must print help, not fail with missing_subcommand.
func TestUnknownSubcommandRunE_GroupValidGlobalFlagShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree()
drive.Root().PersistentFlags().String("profile", "", "") // global, inherited by drive
installUnknownSubcommandGuard(drive.Root())
rawInvocationArgs = []string{"--profile", "p", "drive"}
t.Cleanup(func() { rawInvocationArgs = nil })
var buf bytes.Buffer
drive.SetOut(&buf)
drive.SetErr(&buf)
if err := drive.RunE(drive, nil); err != nil {
t.Fatalf("bare group with only a global flag should print help, got error: %v", err)
}
if !strings.Contains(buf.String(), "drive ops") {
t.Errorf("expected help output, got:\n%s", buf.String())
}
}
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
@@ -257,11 +113,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
}
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
// back to pointing at --help; the full machine-readable list lives in
// detail.available below (which also excludes hidden commands).
if !strings.Contains(exitErr.Detail.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
}
if strings.Contains(exitErr.Detail.Hint, "+secret") {
t.Error("hidden commands must not appear in the hint")
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
@@ -308,7 +164,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
)
got, _ := availableSubcommandNames(root)
got := availableSubcommandNames(root)
want := []string{"alpha", "gamma"}
if len(got) != len(want) {
t.Fatalf("expected %v, got %v", want, got)
@@ -319,61 +175,3 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
}
}
}
func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
root.AddCommand(
&cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
)
available, deprecated := availableSubcommandNames(root)
if len(available) != 1 || available[0] != "+new-cmd" {
t.Errorf("available = %v, want [+new-cmd]", available)
}
if len(deprecated) != 1 || deprecated[0] != "+old-cmd" {
t.Errorf("deprecated = %v, want [+old-cmd]", deprecated)
}
}
// unknownSubcommandRunE must split current vs deprecated subcommands into
// separate detail buckets, while suggestions still rank across both so a
// mistyped legacy alias resolves.
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
svc := &cobra.Command{Use: "sheets"}
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
svc.AddCommand(
&cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
)
err := unknownSubcommandRunE(svc, []string{"+reat"})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
}
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
t.Errorf("available = %v, want [+cells-get]", available)
}
deprecated, ok := detail["deprecated"].([]string)
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
t.Errorf("deprecated = %v, want [+read]", deprecated)
}
// suggestions rank across both buckets: "+reat" is closest to +read.
suggestions, _ := detail["suggestions"].([]string)
found := false
for _, s := range suggestions {
if s == "+read" {
found = true
}
}
if !found {
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
}
}

2
go.mod
View File

@@ -14,7 +14,7 @@ require (
github.com/sergi/go-diff v1.4.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.9
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0

View File

@@ -5,7 +5,6 @@ package cmdpolicy
import (
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/suggest"
)
// suggestRisk returns the closest valid Risk literal by edit distance
@@ -21,9 +20,9 @@ func suggestRisk(bad string) string {
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
}
best := string(candidates[0])
bestDist := suggest.Levenshtein(lowered, best)
bestDist := levenshtein(lowered, best)
for _, c := range candidates[1:] {
if d := suggest.Levenshtein(lowered, string(c)); d < bestDist {
if d := levenshtein(lowered, string(c)); d < bestDist {
bestDist, best = d, string(c)
}
}
@@ -41,3 +40,47 @@ func toLower(s string) string {
}
return string(b)
}
// levenshtein computes the classic edit distance between two strings.
// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set
// makes raw performance irrelevant — clarity beats trickiness here.
func levenshtein(a, b string) int {
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}
prev := make([]int, len(b)+1)
curr := make([]int, len(b)+1)
for j := 0; j <= len(b); j++ {
prev[j] = j
}
for i := 1; i <= len(a); i++ {
curr[0] = i
for j := 1; j <= len(b); j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
curr[j] = min3(
prev[j]+1, // deletion
curr[j-1]+1, // insertion
prev[j-1]+cost, // substitution
)
}
prev, curr = curr, prev
}
return prev[len(b)]
}
func min3(a, b, c int) int {
m := a
if b < m {
m = b
}
if c < m {
m = c
}
return m
}

View File

@@ -29,3 +29,23 @@ func TestSuggestRisk(t *testing.T) {
}
}
}
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"", "abc", 3},
{"abc", "", 3},
{"abc", "abc", 0},
{"wrtie", "write", 2},
{"kitten", "sitting", 3},
}
for _, c := range cases {
got := levenshtein(c.a, c.b)
if got != c.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}

View File

@@ -1,18 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import "github.com/spf13/cobra"
// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility
// command — one kept alive for users whose skill predates a refactor. Service
// registration assigns it (e.g. the sheets pre-refactor aliases); both --help
// rendering and unknown-subcommand suggestions read it to separate these
// aliases from the current commands.
const DeprecatedGroupID = "deprecated"
// IsDeprecatedCommand reports whether c was tagged into the deprecated group.
func IsDeprecatedCommand(c *cobra.Command) bool {
return c != nil && c.GroupID == DeprecatedGroupID
}

View File

@@ -1,57 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package deprecation carries a process-level notice that the command currently
// being executed is a backward-compatibility alias, kept alive for users whose
// skill predates a refactor. The notice is surfaced in JSON output envelopes via
// output.PendingNotice (wired in cmd/root.go), mirroring internal/skillscheck.
//
// A CLI process runs exactly one shortcut, so a single process-level slot is
// sufficient: the command's Execute records the notice before producing output,
// and the output layer reads it back when building the envelope.
package deprecation
import (
"strings"
"sync/atomic"
)
// Notice describes a deprecated command alias and the current command that
// replaces it. Replacement and Skill are optional.
type Notice struct {
Command string `json:"command"`
Replacement string `json:"replacement,omitempty"`
Skill string `json:"skill,omitempty"`
}
// Message returns a single-line, AI-agent-parseable description of the alias
// plus the canonical fix (update the skill). Mirrors the style of
// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update").
func (n *Notice) Message() string {
var b strings.Builder
b.WriteString(n.Command)
b.WriteString(" is a pre-refactor compatibility alias")
if n.Replacement != "" {
b.WriteString("; use ")
b.WriteString(n.Replacement)
b.WriteString(" instead")
}
if n.Skill != "" {
b.WriteString("; update your ")
b.WriteString(n.Skill)
b.WriteString(" skill, run: lark-cli update")
} else {
b.WriteString("; update your skill, run: lark-cli update")
}
return b.String()
}
// pending stores the latest deprecation notice for the current process.
var pending atomic.Pointer[Notice]
// SetPending stores the notice for consumption by output decorators.
// Pass nil to clear.
func SetPending(n *Notice) { pending.Store(n) }
// GetPending returns the pending deprecation notice, or nil.
func GetPending() *Notice { return pending.Load() }

View File

@@ -1,58 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package deprecation
import "testing"
func TestNoticeMessage(t *testing.T) {
tests := []struct {
name string
notice Notice
want string
}{
{
name: "replacement and skill",
notice: Notice{Command: "+read", Replacement: "+cells-get", Skill: "lark-sheets"},
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill, run: lark-cli update",
},
{
name: "no replacement",
notice: Notice{Command: "+read", Skill: "lark-sheets"},
want: "+read is a pre-refactor compatibility alias; update your lark-sheets skill, run: lark-cli update",
},
{
name: "no skill",
notice: Notice{Command: "+read", Replacement: "+cells-get"},
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your skill, run: lark-cli update",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.notice.Message(); got != tt.want {
t.Errorf("Message() =\n %q\nwant\n %q", got, tt.want)
}
})
}
}
func TestSetGetPending(t *testing.T) {
t.Cleanup(func() { SetPending(nil) })
SetPending(nil)
if got := GetPending(); got != nil {
t.Fatalf("expected nil pending after clear, got %#v", got)
}
n := &Notice{Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets"}
SetPending(n)
got := GetPending()
if got == nil || got.Command != "+write" || got.Replacement != "+cells-set" {
t.Fatalf("GetPending() = %#v, want %#v", got, n)
}
SetPending(nil)
if GetPending() != nil {
t.Fatal("expected nil after clearing")
}
}

View File

@@ -1,104 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package suggest provides the shared "did you mean" primitives: a rune-aware
// Levenshtein edit distance and a prefix-weighted Closest ranker. It is the
// single home for these so cmd, cmd/event, and internal/cmdpolicy stop each
// carrying their own copy.
package suggest
import "sort"
// Levenshtein computes the classic edit distance between two strings. It is
// rune-aware, so it is correct for multi-byte input.
func Levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}
// Closest returns up to maxN of candidates that plausibly match typed, ranked
// by shared-prefix length (desc) then edit distance (asc), keeping only
// reasonably-close ones.
//
// Shared prefix is weighted first on purpose: hallucinated names are often
// semantically close but lexically far (e.g. "+cells-find" vs "+cells-search",
// "--with-styles" vs nothing close), where the common prefix is the strongest
// signal of intent that raw edit distance misses.
func Closest(typed string, candidates []string, maxN int) []string {
type scored struct {
name string
prefix int
dist int
}
limit := editLimit(typed)
ranked := make([]scored, 0, len(candidates))
for _, c := range candidates {
p := sharedPrefixLen(typed, c)
d := Levenshtein(typed, c)
// Keep only plausible matches: a meaningful shared prefix, or an edit
// distance within budget. Drop everything else so the hint stays short.
if p >= 3 || d <= limit {
ranked = append(ranked, scored{name: c, prefix: p, dist: d})
}
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].prefix != ranked[j].prefix {
return ranked[i].prefix > ranked[j].prefix
}
if ranked[i].dist != ranked[j].dist {
return ranked[i].dist < ranked[j].dist
}
return ranked[i].name < ranked[j].name
})
if maxN <= 0 || maxN > len(ranked) {
maxN = len(ranked)
}
out := make([]string, 0, maxN)
for _, s := range ranked[:maxN] {
out = append(out, s.name)
}
return out
}
// editLimit allows roughly one third of the typed length in edits (min 2), so
// short names tolerate a couple of typos and longer ones proportionally more.
func editLimit(s string) int {
if l := len([]rune(s)) / 3; l > 2 {
return l
}
return 2
}
func sharedPrefixLen(a, b string) int {
ra, rb := []rune(a), []rune(b)
n := 0
for n < len(ra) && n < len(rb) && ra[n] == rb[n] {
n++
}
return n
}

View File

@@ -1,74 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package suggest
import (
"slices"
"testing"
)
func TestClosest_HallucinatedSharesPrefix(t *testing.T) {
cmds := []string{
"+cells-get", "+cells-set", "+cells-search", "+cells-replace",
"+cells-clear", "+cells-merge", "+csv-get", "+chart-create",
"+pivot-create", "+sheet-info",
}
// "+cells-find" is semantically +cells-search but lexically far; the shared
// "+cells-" prefix should still surface the right family (incl. +cells-search).
got := Closest("+cells-find", cmds, 6)
if len(got) == 0 || len(got) > 6 {
t.Fatalf("expected 1..6 suggestions, got %v", got)
}
if !slices.Contains(got, "+cells-search") {
t.Errorf("expected +cells-search among suggestions, got %v", got)
}
for _, s := range got {
if len(s) < 7 || s[:7] != "+cells-" {
t.Errorf("suggestion %q does not share the +cells- prefix", s)
}
}
}
func TestClosest_TypoRanksExactNeighborFirst(t *testing.T) {
got := Closest("+cell-get", []string{"+cells-get", "+cells-set", "+csv-get", "+sheet-info"}, 3)
if len(got) == 0 || got[0] != "+cells-get" {
t.Errorf("expected +cells-get first for typo +cell-get, got %v", got)
}
}
func TestClosest_NoPlausibleMatch(t *testing.T) {
if got := Closest("+zzzzzz", []string{"+cells-get", "+csv-get"}, 6); len(got) != 0 {
t.Errorf("expected no suggestions for unrelated input, got %v", got)
}
}
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "abc", 3},
{"abc", "", 3},
{"abc", "abc", 0},
{"kitten", "sitting", 3},
{"cell-get", "cells-get", 1},
{"--query", "--find", 5},
{"飞书", "飞书", 0}, // rune-aware: multi-byte equal
{"飞书", "飞s", 1}, // one rune substitution, not byte count
}
for _, c := range cases {
if d := Levenshtein(c.a, c.b); d != c.want {
t.Errorf("Levenshtein(%q,%q) = %d, want %d", c.a, c.b, d, c.want)
}
}
}
func TestSharedPrefixLen(t *testing.T) {
if got := sharedPrefixLen("+cells-find", "+cells-search"); got != 7 {
t.Errorf("sharedPrefixLen = %d, want 7", got)
}
if got := sharedPrefixLen("abc", "xyz"); got != 0 {
t.Errorf("sharedPrefixLen = %d, want 0", got)
}
}

View File

@@ -1,138 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/parser"
"go/token"
"strings"
)
// migratedCommonHelperPaths lists source-tree prefixes whose command validation
// has migrated to typed errs.* envelopes. On these paths, calls to common's
// legacy validation/save helpers are forbidden; callers must use the typed
// common replacements or construct an errs.* typed error directly.
var migratedCommonHelperPaths = []string{
"shortcuts/drive/",
}
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
var legacyCommonHelperReplacements = map[string]string{
"FlagErrorf": "common.ValidationErrorf",
"MutuallyExclusive": "common.MutuallyExclusiveTyped",
"AtLeastOne": "common.AtLeastOneTyped",
"ExactlyOne": "common.ExactlyOneTyped",
"ValidatePageSize": "common.ValidatePageSizeTyped",
"ValidateChatID": "common.ValidateChatIDTyped",
"ValidateUserID": "common.ValidateUserIDTyped",
"ValidateSafePath": "common.ValidateSafePathTyped",
"RejectDangerousChars": "common.RejectDangerousCharsTyped",
"WrapInputStatError": "common.WrapInputStatErrorTyped",
"WrapSaveErrorByCategory": "common.WrapSaveErrorTyped",
"ResolveOpenIDs": "common.ResolveOpenIDsTyped",
"HandleApiResult": "runtime.CallAPITyped",
}
// CheckNoLegacyCommonHelperCall flags any reference to common's legacy helper
// APIs on migrated paths — direct calls and function-value references alike,
// so `f := common.FlagErrorf; f(...)` cannot slip past the guard. These
// helpers return legacy output envelopes or bare errors, so migrated domains
// should use their typed-aware replacements.
func CheckNoLegacyCommonHelperCall(path, src string) []Violation {
if !isMigratedCommonHelperPath(path) || strings.HasSuffix(path, "_test.go") {
return nil
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
if err != nil {
return nil
}
localNames, dotImported := resolveCommonNames(file)
var out []Violation
report := func(pos token.Pos, name, replacement string) {
out = append(out, Violation{
Rule: "no_legacy_common_helper_call",
Action: ActionReject,
File: path,
Line: fset.Position(pos).Line,
Message: "common." + name + " returns a legacy error shape and is forbidden on migrated paths",
Suggestion: "replace common." + name + " with " + replacement + " or a typed errs.* constructor",
})
}
// Pass 1: qualified references (common.X / alias.X). Record every
// selector field so the dot-import pass below never mistakes another
// package's same-named field for a common helper.
selFields := make(map[*ast.Ident]struct{})
ast.Inspect(file, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok {
return true
}
selFields[sel.Sel] = struct{}{}
x, ok := sel.X.(*ast.Ident)
if !ok {
return true
}
if _, bound := localNames[x.Name]; !bound {
return true
}
if replacement, ok := legacyCommonHelperReplacements[sel.Sel.Name]; ok {
report(sel.Pos(), sel.Sel.Name, replacement)
}
return true
})
// Pass 2: unqualified references under a dot import.
if dotImported {
ast.Inspect(file, func(n ast.Node) bool {
ident, ok := n.(*ast.Ident)
if !ok {
return true
}
if _, isField := selFields[ident]; isField {
return true
}
if replacement, ok := legacyCommonHelperReplacements[ident.Name]; ok {
report(ident.Pos(), ident.Name, replacement)
}
return true
})
}
return out
}
func isMigratedCommonHelperPath(path string) bool {
p := strings.ReplaceAll(path, "\\", "/")
for _, prefix := range migratedCommonHelperPaths {
if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) {
return true
}
}
return false
}
func resolveCommonNames(file *ast.File) (map[string]struct{}, bool) {
names := make(map[string]struct{})
dotImported := false
for _, imp := range file.Imports {
if imp.Path == nil {
continue
}
p := strings.Trim(imp.Path.Value, "`\"")
if p != commonImportPath {
continue
}
switch {
case imp.Name == nil:
names["common"] = struct{}{}
case imp.Name.Name == ".":
dotImported = true
case imp.Name.Name == "_":
default:
names[imp.Name.Name] = struct{}{}
}
}
return names, dotImported
}

View File

@@ -877,123 +877,3 @@ func boom(runtime *common.RuntimeContext) error {
t.Errorf("test files must be skipped, got: %+v", v)
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *testing.T) {
helpers := []string{
"FlagErrorf",
"MutuallyExclusive",
"AtLeastOne",
"ExactlyOne",
"ValidatePageSize",
"ValidateChatID",
"ValidateUserID",
"ValidateSafePath",
"RejectDangerousChars",
"WrapInputStatError",
"WrapSaveErrorByCategory",
"ResolveOpenIDs",
"HandleApiResult",
}
for _, helper := range helpers {
t.Run(helper, func(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.` + helper + `()
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for %s, got %d: %+v", helper, len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "common."+helper) {
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
}
})
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
src := `package im
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.FlagErrorf("legacy allowed until domain migrates")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must pass, got: %+v", v)
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsTypedHelpersOnMigratedPath(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.ValidationErrorf("typed")
common.MutuallyExclusiveTyped(nil, "a", "b")
common.ValidateChatIDTyped("--chat-ids", "oc_abc")
common.ResolveOpenIDsTyped("--user-ids", nil, nil)
common.WrapSaveErrorTyped(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 0 {
t.Errorf("typed helpers must pass, got: %+v", v)
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsAliasedImport(t *testing.T) {
src := `package drive
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
c.FlagErrorf("legacy")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for aliased common import, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsDotImport(t *testing.T) {
src := `package drive
import . "github.com/larksuite/cli/shortcuts/common"
func boom() {
FlagErrorf("legacy")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for dot-imported common, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsFunctionValueReference(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/shortcuts/common"
func boom() error {
f := common.FlagErrorf
return f("legacy")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
}
}

View File

@@ -108,7 +108,6 @@ func ScanRepo(root string) ([]Violation, error) {
all = append(all, CheckTypedErrorCompleteness(rel, string(src))...)
all = append(all, CheckNoLegacyEnvelopeLiteral(rel, string(src))...)
all = append(all, CheckNoLegacyRuntimeAPICall(rel, string(src))...)
all = append(all, CheckNoLegacyCommonHelperCall(rel, string(src))...)
// Typed-error invariants — self-scope to errs/ + classify.go.
all = append(all, CheckNilSafeError(rel, string(src))...)
all = append(all, CheckUnwrapSymmetry(rel, string(src))...)

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.47",
"version": "1.0.46",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -1,42 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseBaseBlockCreate = common.Shortcut{
Service: "base",
Command: "+base-block-create",
Description: "Create a block",
Risk: "write",
Scopes: []string{"base:block:create"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "type", Desc: "resource type", Required: true, Enum: baseBlockTypeEnums},
{Name: "name", Desc: "block name", Required: true},
{Name: "parent-id", Desc: "folder block id; when omitted, create at root"},
},
Tips: []string{
"Example: lark-cli base +base-block-create --base-token <base_token> --type folder --name \"Project Docs\"",
"Example: lark-cli base +base-block-create --base-token <base_token> --type table --name \"Tasks\"",
"Example: lark-cli base +base-block-create --base-token <base_token> --type docx --name \"Spec\" --parent-id <folder_block_id>",
"Example: lark-cli base +base-block-create --base-token <base_token> --type dashboard --name \"Metrics\"",
"Example: lark-cli base +base-block-create --base-token <base_token> --type workflow --name \"Approval Flow\"",
"Creates a folder, table, docx, dashboard, or workflow entry.",
"Do not pass null for --parent-id. Omit it to create at the root level.",
"Created resources still use their own commands for content operations, such as table/field/record/docx/dashboard/workflow commands.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBaseBlockCreate(runtime)
},
DryRun: dryRunBaseBlockCreate,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseBlockCreate(runtime)
},
}

View File

@@ -1,35 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseBaseBlockDelete = common.Shortcut{
Service: "base",
Command: "+base-block-delete",
Description: "Delete a block",
Risk: "high-risk-write",
Scopes: []string{"base:block:delete"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
baseBlockIDFlag(true),
},
Tips: []string{
"Example: lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
"Deletes the block identified by --block-id.",
"Recursive folder deletion is not supported. If a folder is not empty, move or delete its children first.",
"Different block types may have independent backing resources; deletion follows backend semantics.",
"Use +base-block-list first when you need to confirm the target block id.",
"If the user already explicitly confirmed this exact delete target, pass --yes without asking again.",
},
DryRun: dryRunBaseBlockDelete,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseBlockDelete(runtime)
},
}

View File

@@ -1,43 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseBaseBlockList = common.Shortcut{
Service: "base",
Command: "+base-block-list",
Description: "List blocks in a base",
Risk: "read",
Scopes: []string{"base:block:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "type", Desc: "filter by resource type", Enum: baseBlockTypeEnums},
{Name: "parent-id", Desc: "folder block id; when omitted, list all blocks"},
},
Tips: []string{
"Example: lark-cli base +base-block-list --base-token <base_token>",
"Example: lark-cli base +base-block-list --base-token <base_token> --type table",
"Example: lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
`JQ crop: lark-cli base +base-block-list --base-token <base_token> | jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
`JQ crop docx: lark-cli base +base-block-list --base-token <base_token> --type docx | jq '.blocks[] | {name, docx_token}'`,
"Blocks are resources managed directly by the base, such as folder, table, docx, dashboard, and workflow.",
"For table, dashboard, and workflow blocks, returned id is the table-id, dashboard-id, or workflow-id used by the corresponding commands.",
"For docx blocks, use the returned docx_token with docx commands.",
"For folder blocks, pass the returned id as --parent-id when creating, listing, or moving blocks inside that folder.",
"This command returns the full backend list. It intentionally does not expose limit or offset.",
"Pass --type to list only one resource type.",
"Pass --parent-id to list only direct children of a folder.",
"Dashboard blocks are chart/widget blocks inside a dashboard; use +dashboard-block-* for those.",
},
DryRun: dryRunBaseBlockList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseBlockList(runtime)
},
}

View File

@@ -1,42 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseBaseBlockMove = common.Shortcut{
Service: "base",
Command: "+base-block-move",
Description: "Move a block",
Risk: "write",
Scopes: []string{"base:block:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
baseBlockIDFlag(true),
{Name: "parent-id", Desc: "target folder block id; when omitted, move to root"},
{Name: "before-id", Desc: "sibling block id; move the block before this sibling in the target folder/root order"},
{Name: "after-id", Desc: "sibling block id; move the block after this sibling in the target folder/root order"},
},
Tips: []string{
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
"Omit --parent-id to move the block to root; do not pass null.",
"--before-id and --after-id are mutually exclusive.",
"When moving a folder, its children remain under that folder.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBaseBlockMove(runtime)
},
DryRun: dryRunBaseBlockMove,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseBlockMove(runtime)
},
}

View File

@@ -1,179 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var baseBlockTypeEnums = []string{"folder", "table", "docx", "dashboard", "workflow"}
func baseBlockIDFlag(required bool) common.Flag {
return common.Flag{Name: "block-id", Desc: "block id", Required: required}
}
func dryRunBaseBlockList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/blocks/list").
Body(buildBaseBlockListBody(runtime)).
Set("base_token", runtime.Str("base-token"))
}
func dryRunBaseBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/blocks").
Body(buildBaseBlockCreateBody(runtime)).
Set("base_token", runtime.Str("base-token"))
}
func dryRunBaseBlockMove(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/move").
Body(buildBaseBlockMoveBody(runtime)).
Set("base_token", runtime.Str("base-token")).
Set("block_id", runtime.Str("block-id"))
}
func dryRunBaseBlockRename(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/rename").
Body(map[string]interface{}{"name": strings.TrimSpace(runtime.Str("name"))}).
Set("base_token", runtime.Str("base-token")).
Set("block_id", runtime.Str("block-id"))
}
func dryRunBaseBlockDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/blocks/:block_id").
Set("base_token", runtime.Str("base-token")).
Set("block_id", runtime.Str("block-id"))
}
func validateBaseBlockCreate(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("name")) == "" {
return common.FlagErrorf("--name must not be blank")
}
if strings.TrimSpace(runtime.Str("type")) == "" {
return common.FlagErrorf("--type must not be blank")
}
return nil
}
func validateBaseBlockMove(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("before-id")) != "" && strings.TrimSpace(runtime.Str("after-id")) != "" {
return common.FlagErrorf("--before-id and --after-id are mutually exclusive")
}
return nil
}
func validateBaseBlockRename(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("name")) == "" {
return common.FlagErrorf("--name must not be blank")
}
return nil
}
func executeBaseBlockList(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", "list"), nil, buildBaseBlockListBody(runtime))
if err != nil {
return err
}
filterBaseBlockListData(data, strings.TrimSpace(runtime.Str("type")))
runtime.Out(data, nil)
return nil
}
func executeBaseBlockCreate(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks"), nil, buildBaseBlockCreateBody(runtime))
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"block": data, "created": true}, nil)
return nil
}
func executeBaseBlockMove(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "move"), nil, buildBaseBlockMoveBody(runtime))
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"block": data, "moved": true}, nil)
return nil
}
func executeBaseBlockRename(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "rename"), nil, map[string]interface{}{
"name": strings.TrimSpace(runtime.Str("name")),
})
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"block": data, "renamed": true}, nil)
return nil
}
func executeBaseBlockDelete(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"block": data, "deleted": true}, nil)
return nil
}
func buildBaseBlockListBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{}
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
body["parent_id"] = parentID
}
return body
}
func filterBaseBlockListData(data map[string]interface{}, blockType string) {
if blockType == "" {
return
}
blocks, ok := data["blocks"].([]interface{})
if !ok {
return
}
filtered := make([]interface{}, 0, len(blocks))
for _, block := range blocks {
blockMap, ok := block.(map[string]interface{})
if !ok || blockMap["type"] != blockType {
continue
}
filtered = append(filtered, block)
}
data["blocks"] = filtered
data["total"] = len(filtered)
}
func buildBaseBlockCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"type": strings.TrimSpace(runtime.Str("type")),
"name": strings.TrimSpace(runtime.Str("name")),
}
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
body["parent_id"] = parentID
}
return body
}
func buildBaseBlockMoveBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{"parent_id": nil}
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
body["parent_id"] = parentID
}
if beforeID := strings.TrimSpace(runtime.Str("before-id")); beforeID != "" {
body["before_id"] = beforeID
}
if afterID := strings.TrimSpace(runtime.Str("after-id")); afterID != "" {
body["after_id"] = afterID
}
return body
}

View File

@@ -1,37 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseBaseBlockRename = common.Shortcut{
Service: "base",
Command: "+base-block-rename",
Description: "Rename a block",
Risk: "write",
Scopes: []string{"base:block:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
baseBlockIDFlag(true),
{Name: "name", Desc: "new unique block name; must not duplicate another block name in this base", Required: true},
},
Tips: []string{
"Example: lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name \"New name\"",
"Renames the block identified by --block-id.",
"Block names must be unique in the base; use +base-block-list first when you need to check existing names.",
"Use +base-block-list first when you need to resolve the target block id from a visible name.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBaseBlockRename(runtime)
},
DryRun: dryRunBaseBlockRename,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseBlockRename(runtime)
},
}

View File

@@ -32,29 +32,6 @@ func TestDryRunTableOps(t *testing.T) {
assertDryRunContains(t, dryRunTableDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1")
}
func TestDryRunBaseBlockOps(t *testing.T) {
ctx := context.Background()
listRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockList(ctx, listRT), "POST /open-apis/base/v3/bases/app_x/blocks/list")
listFolderRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "parent-id": "bfl_1", "type": "docx"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockList(ctx, listFolderRT), "POST /open-apis/base/v3/bases/app_x/blocks/list", `"parent_id":"bfl_1"`)
createRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "type": "docx", "name": "Spec", "parent-id": "bfl_1"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockCreate(ctx, createRT), "POST /open-apis/base/v3/bases/app_x/blocks", `"type":"docx"`, `"name":"Spec"`, `"parent_id":"bfl_1"`)
moveRootRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveRootRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":null`)
moveAfterRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "parent-id": "bfl_1", "after-id": "blk_0"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveAfterRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":"bfl_1"`, `"after_id":"blk_0"`)
renameRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "name": "New name"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockRename(ctx, renameRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/rename", `"name":"New name"`)
assertDryRunContains(t, dryRunBaseBlockDelete(ctx, renameRT), "DELETE /open-apis/base/v3/bases/app_x/blocks/blk_1")
}
func TestDryRunFieldOps(t *testing.T) {
ctx := context.Background()

View File

@@ -411,108 +411,6 @@ func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interf
return body
}
func TestBaseBlockExecuteShortcuts(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
listStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks/list",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"blocks": []interface{}{
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec"},
map[string]interface{}{"id": "blk_folder", "type": "folder", "name": "Folder"},
},
"total": 2,
},
},
}
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc", "type": "docx", "name": "Spec"},
},
}
moveStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/move",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc", "parent_id": "bfl_1"},
},
}
renameStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/rename",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc", "name": "Final Spec"},
},
}
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc"},
},
}
for _, stub := range []*httpmock.Stub{listStub, createStub, moveStub, renameStub, deleteStub} {
reg.Register(stub)
}
if err := runShortcut(t, BaseBaseBlockList, []string{"+base-block-list", "--base-token", "app_x", "--parent-id", "bfl_1", "--type", "docx"}, factory, stdout); err != nil {
t.Fatalf("list err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"total": 1`) || !strings.Contains(got, `"blk_doc"`) || strings.Contains(got, `"blk_folder"`) {
t.Fatalf("list stdout=%s", got)
}
if body := decodeCapturedJSONBody(t, listStub); body["parent_id"] != "bfl_1" || body["type"] != nil {
t.Fatalf("list body=%#v", body)
}
if err := runShortcut(t, BaseBaseBlockCreate, []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " Spec ", "--parent-id", "bfl_1"}, factory, stdout); err != nil {
t.Fatalf("create err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"blk_doc"`) {
t.Fatalf("create stdout=%s", got)
}
createBody := decodeCapturedJSONBody(t, createStub)
if createBody["type"] != "docx" || createBody["name"] != "Spec" || createBody["parent_id"] != "bfl_1" {
t.Fatalf("create body=%#v", createBody)
}
if err := runShortcut(t, BaseBaseBlockMove, []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--parent-id", "bfl_1", "--after-id", "blk_prev"}, factory, stdout); err != nil {
t.Fatalf("move err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"moved": true`) {
t.Fatalf("move stdout=%s", got)
}
moveBody := decodeCapturedJSONBody(t, moveStub)
if moveBody["parent_id"] != "bfl_1" || moveBody["after_id"] != "blk_prev" {
t.Fatalf("move body=%#v", moveBody)
}
if err := runShortcut(t, BaseBaseBlockRename, []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " Final Spec "}, factory, stdout); err != nil {
t.Fatalf("rename err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"renamed": true`) || !strings.Contains(got, `"Final Spec"`) {
t.Fatalf("rename stdout=%s", got)
}
if body := decodeCapturedJSONBody(t, renameStub); body["name"] != "Final Spec" {
t.Fatalf("rename body=%#v", body)
}
if err := runShortcut(t, BaseBaseBlockDelete, []string{"+base-block-delete", "--base-token", "app_x", "--block-id", "blk_doc", "--yes"}, factory, stdout); err != nil {
t.Fatalf("delete err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"blk_doc"`) {
t.Fatalf("delete stdout=%s", got)
}
}
func TestBaseHistoryExecute(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{

View File

@@ -133,7 +133,6 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
func TestShortcutsCatalog(t *testing.T) {
shortcuts := Shortcuts()
want := []string{
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
@@ -189,7 +188,6 @@ func TestBaseDeleteShortcutsRisk(t *testing.T) {
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
BaseBaseBlockDelete.Command: BaseBaseBlockDelete.Risk,
BaseRoleDelete.Command: BaseRoleDelete.Risk,
}
@@ -243,30 +241,6 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
}
}
func TestBaseBlockMoveRejectsBeforeAndAfter(t *testing.T) {
runtime := newBaseTestRuntime(
map[string]string{"before-id": "blk_before", "after-id": "blk_after"},
nil,
nil,
)
err := validateBaseBlockMove(runtime)
if err == nil || !strings.Contains(err.Error(), "--before-id and --after-id are mutually exclusive") {
t.Fatalf("err=%v", err)
}
}
func TestBaseBlockCreateAndRenameRequireName(t *testing.T) {
createRT := newBaseTestRuntime(map[string]string{"type": "folder", "name": " "}, nil, nil)
if err := validateBaseBlockCreate(createRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
t.Fatalf("create err=%v", err)
}
renameRT := newBaseTestRuntime(map[string]string{"name": " "}, nil, nil)
if err := validateBaseBlockRename(renameRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
t.Fatalf("rename err=%v", err)
}
}
func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
@@ -754,79 +728,6 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
}
}
func TestBaseBlockHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantTips []string
}{
{
name: "list",
shortcut: BaseBaseBlockList,
wantTips: []string{
"lark-cli base +base-block-list --base-token <base_token>",
"lark-cli base +base-block-list --base-token <base_token> --type table",
"lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
`jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
`--type docx | jq '.blocks[] | {name, docx_token}'`,
"returned id is the table-id, dashboard-id, or workflow-id",
"For docx blocks, use the returned docx_token with docx commands.",
},
},
{
name: "create",
shortcut: BaseBaseBlockCreate,
wantTips: []string{
`lark-cli base +base-block-create --base-token <base_token> --type folder --name "Project Docs"`,
`lark-cli base +base-block-create --base-token <base_token> --type table --name "Tasks"`,
`lark-cli base +base-block-create --base-token <base_token> --type docx --name "Spec" --parent-id <folder_block_id>`,
`lark-cli base +base-block-create --base-token <base_token> --type dashboard --name "Metrics"`,
`lark-cli base +base-block-create --base-token <base_token> --type workflow --name "Approval Flow"`,
},
},
{
name: "move",
shortcut: BaseBaseBlockMove,
wantTips: []string{
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
},
},
{
name: "rename",
shortcut: BaseBaseBlockRename,
wantTips: []string{
`lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name "New name"`,
},
},
{
name: "delete",
shortcut: BaseBaseBlockDelete,
wantTips: []string{
"lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
"Recursive folder deletion is not supported.",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})

View File

@@ -8,11 +8,6 @@ import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all base shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
BaseBaseBlockList,
BaseBaseBlockCreate,
BaseBaseBlockMove,
BaseBaseBlockRename,
BaseBaseBlockDelete,
BaseTableList,
BaseTableGet,
BaseTableCreate,

View File

@@ -164,9 +164,6 @@ func CheckApiError(w io.Writer, result interface{}, action string) bool {
}
// HandleApiResult checks for network/API errors and returns the "data" field.
//
// Deprecated: use RuntimeContext.CallAPITyped (or ClassifyAPIResponse for
// self-driven requests) for typed error envelopes.
func HandleApiResult(result interface{}, err error, action string) (map[string]interface{}, error) {
if err != nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)

View File

@@ -30,7 +30,6 @@ import (
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// RuntimeContext provides helpers for shortcut execution.
@@ -73,16 +72,6 @@ func (ctx *RuntimeContext) IsBot() bool {
return ctx.As().IsBot()
}
// Command returns the shortcut command name as cobra knows it (e.g.
// "+pivot-create"). Used by per-service helpers (e.g. sheets schema
// validation) that key off the shortcut identity.
func (ctx *RuntimeContext) Command() string {
if ctx.Cmd == nil {
return ""
}
return ctx.Cmd.Name()
}
// UserOpenId returns the current user's open_id from config.
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
@@ -211,12 +200,6 @@ func (ctx *RuntimeContext) Int(name string) int {
return v
}
// Float64 returns a float64 flag value (non-integer numbers).
func (ctx *RuntimeContext) Float64(name string) float64 {
v, _ := ctx.Cmd.Flags().GetFloat64(name)
return v
}
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
func (ctx *RuntimeContext) StrArray(name string) []string {
v, _ := ctx.Cmd.Flags().GetStringArray(name)
@@ -642,8 +625,6 @@ func WrapOpenError(err error, pathMsg, readMsg string) error {
// - Other errors → readMsg prefix (default "cannot read file")
//
// Pass an optional readMsg to override the non-path-validation message prefix.
//
// Deprecated: use WrapInputStatErrorTyped for typed error envelopes.
func WrapInputStatError(err error, readMsg ...string) error {
if err == nil {
return nil
@@ -658,28 +639,9 @@ func WrapInputStatError(err error, readMsg ...string) error {
return output.ErrValidation("%s: %s", msg, err)
}
// WrapInputStatErrorTyped wraps a FileIO.Stat/Open error for input file validation.
func WrapInputStatErrorTyped(err error, readMsg ...string) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).
WithCause(err)
}
msg := "cannot read file"
if len(readMsg) > 0 && readMsg[0] != "" {
msg = readMsg[0]
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).
WithCause(err)
}
// WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors,
// using standardized messages and the given error category (e.g. "api_error", "io").
// Path validation errors always use ErrValidation (exit code 2).
//
// Deprecated: use WrapSaveErrorTyped for typed error envelopes.
func WrapSaveErrorByCategory(err error, category string) error {
if err == nil {
return nil
@@ -695,28 +657,6 @@ func WrapSaveErrorByCategory(err error, category string) error {
}
}
// WrapSaveErrorTyped maps a FileIO.Save error to typed validation/internal errors.
// Unlike WrapSaveErrorByCategory, non-path failures always emit the canonical
// "internal" wire type: call sites migrating from a custom category
// (e.g. "io", "api_error") change their envelope's type field.
func WrapSaveErrorTyped(err error) error {
if err == nil {
return nil
}
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).
WithCause(err)
case errors.As(err, &me):
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).
WithCause(err)
default:
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).
WithCause(err)
}
}
// ValidatePath checks that path is a valid relative input path within the
// working directory by delegating to FileIO.Stat. Returns nil if the path is
// valid or does not exist yet; returns an error only for illegal paths
@@ -955,29 +895,6 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
return runShortcut(cmd, f, &shortcut, botOnly)
},
}
if shortcut.PrintFlagSchema != nil || shortcut.OnInvoke != nil {
onInvoke := shortcut.OnInvoke
relaxRequiredForSchema := shortcut.PrintFlagSchema != nil
// PreRunE runs before cobra's ValidateRequiredFlags. Two opt-in uses:
// - OnInvoke: fire a side effect (e.g. a deprecation notice) that must
// surface even when the call later fails on a missing required flag.
// - --print-schema: pure local introspection; relax the required-flag
// gate so callers don't fill in unrelated flags just to ask for a
// schema (clearing the annotation here is the supported opt-out).
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
if onInvoke != nil {
onInvoke()
}
if relaxRequiredForSchema {
if want, _ := c.Flags().GetBool("print-schema"); want {
c.Flags().VisitAll(func(fl *pflag.Flag) {
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
})
}
}
return nil
}
}
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
cmdutil.SetTips(cmd, shortcut.Tips)
@@ -991,31 +908,6 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
// runShortcut is the execution pipeline for a declarative shortcut.
// Each step is a clear phase: identity → config → scopes → context → validate → execute.
func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
// --print-schema short-circuits everything below: it's pure local
// introspection, no identity / scope / network needed. The flag is
// only registered when the shortcut opts in via PrintFlagSchema.
if s.PrintFlagSchema != nil {
if want, _ := cmd.Flags().GetBool("print-schema"); want {
flagName, _ := cmd.Flags().GetString("flag-name")
out, err := s.PrintFlagSchema(strings.TrimSpace(flagName))
if err != nil {
// PrintFlagSchema implementations return bare errors; wrap as a
// structured ExitError so --print-schema (an agent-facing
// introspection path) yields a parseable envelope, not a plain
// string.
if _, ok := err.(*output.ExitError); !ok {
err = output.Errorf(output.ExitValidation, "print_schema_error", "%s", err.Error())
}
return err
}
if len(out) == 0 {
return nil
}
fmt.Fprintln(f.IOStreams.Out, string(out))
return nil
}
}
as, err := resolveShortcutIdentity(cmd, f, s)
if err != nil {
return err
@@ -1120,16 +1012,6 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
return rctx, nil
}
// stripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a
// file or stdin. A BOM that survives into a CSV cell corrupts the first value
// (e.g. "\ufeffNorth", which then makes a MAXIFS/lookup miss it), and a BOM at the
// head of a JSON payload makes json.Unmarshal fail with "invalid character 'ï'".
// Some editors and exporters add it silently. Only a leading BOM is removed; interior
// occurrences are left untouched.
func stripUTF8BOM(s string) string {
return strings.TrimPrefix(s, "\uFEFF")
}
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
@@ -1140,8 +1022,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
}
raw, err := rctx.Cmd.Flags().GetString(fl.Name)
if err != nil {
return ValidationErrorf("--%s: Input is only supported for string flags", fl.Name).
WithParam("--" + fl.Name)
return FlagErrorf("--%s: Input is only supported for string flags", fl.Name)
}
if raw == "" {
continue
@@ -1150,23 +1031,17 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
// stdin: -
if raw == "-" {
if !slices.Contains(fl.Input, Stdin) {
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
WithParam("--" + fl.Name)
return FlagErrorf("--%s does not support stdin (-)", fl.Name)
}
if stdinUsed {
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
WithParam("--" + fl.Name)
return FlagErrorf("--%s: stdin (-) can only be used by one flag", fl.Name)
}
stdinUsed = true
data, err := io.ReadAll(rctx.IO().In)
if err != nil {
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
WithParam("--" + fl.Name).
WithCause(err)
return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err)
}
// strip a leading UTF-8 BOM so it can't corrupt the first CSV
// cell or break JSON parsing downstream.
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
rctx.Cmd.Flags().Set(fl.Name, string(data))
continue
}
@@ -1179,23 +1054,17 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
// file: @path
if strings.HasPrefix(raw, "@") {
if !slices.Contains(fl.Input, File) {
return ValidationErrorf("--%s does not support file input (@path)", fl.Name).
WithParam("--" + fl.Name)
return FlagErrorf("--%s does not support file input (@path)", fl.Name)
}
path := strings.TrimSpace(raw[1:])
if path == "" {
return ValidationErrorf("--%s: file path cannot be empty after @", fl.Name).
WithParam("--" + fl.Name)
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
}
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil {
return ValidationErrorf("--%s: %v", fl.Name, err).
WithParam("--" + fl.Name).
WithCause(err)
return FlagErrorf("--%s: %v", fl.Name, err)
}
// strip a leading UTF-8 BOM so it
// can't corrupt the first CSV cell or break JSON parsing downstream.
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
rctx.Cmd.Flags().Set(fl.Name, string(data))
continue
}
}
@@ -1219,8 +1088,7 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
}
}
if !valid {
return ValidationErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", ")).
WithParam("--" + fl.Name)
return FlagErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", "))
}
}
return nil
@@ -1228,8 +1096,7 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error {
if s.DryRun == nil {
return ValidationErrorf("--dry-run is not supported for %s %s", s.Service, s.Command).
WithParam("--dry-run")
return FlagErrorf("--dry-run is not supported for %s %s", s.Service, s.Command)
}
fmt.Fprintln(f.IOStreams.ErrOut, "=== Dry Run ===")
dryResult := s.DryRun(rctx.ctx, rctx)
@@ -1282,10 +1149,6 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
var d int
fmt.Sscanf(fl.Default, "%d", &d)
cmd.Flags().Int(fl.Name, d, desc)
case "float64":
var d float64
fmt.Sscanf(fl.Default, "%g", &d)
cmd.Flags().Float64(fl.Name, d, desc)
case "string_array":
cmd.Flags().StringArray(fl.Name, nil, desc)
case "string_slice":
@@ -1320,17 +1183,6 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
if s.Risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}
if s.PrintFlagSchema != nil {
// Guard against a shortcut that already declares these reserved
// introspection flags: pflag panics on a duplicate registration.
// Mirrors the Lookup guard on --format above.
if cmd.Flags().Lookup("print-schema") == nil {
cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing")
}
if cmd.Flags().Lookup("flag-name") == nil {
cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema")
}
}
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
}

View File

@@ -97,46 +97,6 @@ func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
}
}
// TestShortcutMount_ReservedIntrospectionFlagCollision verifies the reserved
// --print-schema / --flag-name flags are registered defensively: a shortcut
// that already declares same-named flags must not trigger pflag's duplicate-
// registration panic (the Lookup guard in registerShortcutFlagsWithContext).
func TestShortcutMount_ReservedIntrospectionFlagCollision(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "docs",
Command: "+introspect",
Description: "x",
// The shortcut's own flags collide with the names the runner auto-
// injects when PrintFlagSchema is set. Without the guard, pflag panics.
Flags: []Flag{
{Name: "print-schema", Desc: "user-defined collision"},
{Name: "flag-name", Desc: "user-defined collision"},
},
PrintFlagSchema: func(string) ([]byte, error) { return nil, nil },
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("Mount panicked on a reserved-flag name collision (Lookup guard missing?): %v", r)
}
}()
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+introspect"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
if cmd.Flags().Lookup("print-schema") == nil {
t.Error("print-schema flag should still exist after the guarded registration")
}
if cmd.Flags().Lookup("flag-name") == nil {
t.Error("flag-name flag should still exist after the guarded registration")
}
}
func TestShortcutMount_JsonFlag_AcceptedWhenHasFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}

View File

@@ -129,7 +129,6 @@ func TestResolveInputFlags_StdinNotSupported(t *testing.T) {
if err == nil {
t.Fatal("expected error for stdin not supported")
}
assertValidationParam(t, err, "--data")
if !strings.Contains(err.Error(), "does not support stdin") {
t.Errorf("unexpected error: %v", err)
}
@@ -143,7 +142,6 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
if err == nil {
t.Fatal("expected error for file not supported")
}
assertValidationParam(t, err, "--data")
if !strings.Contains(err.Error(), "does not support file input") {
t.Errorf("unexpected error: %v", err)
}
@@ -160,7 +158,6 @@ func TestResolveInputFlags_FileNotFound(t *testing.T) {
if err == nil {
t.Fatal("expected error for missing file")
}
assertValidationParam(t, err, "--markdown")
if !strings.Contains(err.Error(), "cannot read file") {
t.Errorf("unexpected error: %v", err)
}
@@ -174,7 +171,6 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
if err == nil {
t.Fatal("expected error for empty file path")
}
assertValidationParam(t, err, "--markdown")
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
t.Errorf("unexpected error: %v", err)
}
@@ -216,58 +212,7 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
if err == nil {
t.Fatal("expected error for duplicate stdin usage")
}
assertValidationParam(t, err, "--b")
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
t.Errorf("unexpected error: %v", err)
}
}
func TestStripUTF8BOM(t *testing.T) {
cases := []struct{ name, in, want string }{
{"leading BOM removed", "\uFEFFhello", "hello"},
{"no BOM unchanged", "hello", "hello"},
{"empty unchanged", "", ""},
{"only BOM becomes empty", "\uFEFF", ""},
{"interior BOM preserved", "a\uFEFFb", "a\uFEFFb"},
{"only the first BOM removed", "\uFEFF\uFEFFx", "\uFEFFx"},
}
for _, c := range cases {
if got := stripUTF8BOM(c.in); got != c.want {
t.Errorf("%s: stripUTF8BOM(%q) = %q, want %q", c.name, c.in, got, c.want)
}
}
}
func TestResolveInputFlags_StripBOMStdin(t *testing.T) {
// A CSV piped via stdin with a leading BOM (e.g. from an upstream export)
// must reach the shortcut without the BOM, so it can't corrupt the first cell.
rctx := newTestRuntimeWithStdin(map[string]string{"csv": "-"}, "\uFEFFname,age\nzhang,8")
flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("csv"); got != "name,age\nzhang,8" {
t.Errorf("leading BOM not stripped from stdin, got %q", got)
}
}
func TestResolveInputFlags_StripBOMFile(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
// A JSON operations file saved with a BOM would otherwise fail json.Unmarshal
// with "invalid character 'ï'".
if err := os.WriteFile("ops.json", []byte("\uFEFF[{\"shortcut\":\"+cells-set\"}]"), 0644); err != nil {
t.Fatal(err)
}
rctx := newTestRuntimeWithStdin(map[string]string{"operations": "@ops.json"}, "")
flags := []Flag{{Name: "operations", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("operations"); got != "[{\"shortcut\":\"+cells-set\"}]" {
t.Errorf("leading BOM not stripped from file, got %q", got)
}
}

View File

@@ -1,22 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import "testing"
func TestValidateEnumFlags_ReturnsTypedValidation(t *testing.T) {
rctx := newTestRuntime(map[string]string{"mode": "delete"})
err := validateEnumFlags(rctx, []Flag{
{Name: "mode", Enum: []string{"append", "overwrite"}},
})
assertValidationParam(t, err, "--mode")
}
func TestHandleShortcutDryRunUnsupported_ReturnsTypedValidation(t *testing.T) {
err := handleShortcutDryRun(nil, nil, &Shortcut{
Service: "doc",
Command: "fetch",
})
assertValidationParam(t, err, "--dry-run")
}

View File

@@ -18,7 +18,7 @@ const (
// Flag describes a CLI flag for a shortcut.
type Flag struct {
Name string // flag name (e.g. "calendar-id")
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
Type string // "string" (default) | "bool" | "int" | "string_array" | "string_slice"
Default string // default value as string
Desc string // help text
Hidden bool // hidden from --help, still readable at runtime
@@ -58,29 +58,6 @@ type Shortcut struct {
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
// OnInvoke, when non-nil, runs from the command's cobra PreRunE — before
// cobra validates required flags — so its side effect fires even when the
// call later fails on a missing required flag (which short-circuits before
// Validate/Execute). The backward-compat aliases use it to record a
// deprecation notice that must surface regardless of whether the call
// validates. Fire-and-forget: no args, no return (e.g. deprecation.SetPending).
OnInvoke func()
// PrintFlagSchema, when non-nil, opts this shortcut into the
// `--print-schema --flag-name <name>` runtime introspection contract.
// The framework auto-injects those two system flags and short-circuits
// Validate/Execute when --print-schema is set, dispatching to this hook.
//
// Contract:
// - flagName == "" → list the flags this shortcut can describe
// (output is impl-defined; agents read this to
// discover which flags are introspectable).
// - flagName == "...": → return the JSON Schema (or schema-like blob)
// for that flag.
// Return value is written to stdout verbatim; callers typically format
// it as JSON. Returning an error surfaces as a normal command error.
PrintFlagSchema func(flagName string) ([]byte, error)
// PostMount is an optional hook called after the cobra.Command is fully
// configured (flags registered, tips set) and after parent.AddCommand(cmd)
// has attached it to the parent. Use it to install custom help functions or

View File

@@ -4,7 +4,6 @@
package common
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
@@ -14,32 +13,9 @@ import (
// open_id, removes duplicates case-insensitively while preserving the
// first-occurrence form, and returns nil for an empty input. flagName is
// used in error messages to point the user at the offending CLI flag.
//
// Deprecated: use ResolveOpenIDsTyped for typed error envelopes.
func ResolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]string, error) {
out, msg := resolveOpenIDs(flagName, ids, runtime)
if msg != "" {
return nil, output.ErrValidation("%s", msg)
}
return out, nil
}
// ResolveOpenIDsTyped expands the special identifier "me" to the current
// user's open_id, removes duplicates case-insensitively while preserving the
// first-occurrence form, and returns nil for an empty input. flagName names
// the flag being resolved (e.g. "--user-ids") and is recorded on the typed
// error.
func ResolveOpenIDsTyped(flagName string, ids []string, runtime *RuntimeContext) ([]string, error) {
out, msg := resolveOpenIDs(flagName, ids, runtime)
if msg != "" {
return nil, ValidationErrorf("%s", msg).WithParam(flagName)
}
return out, nil
}
func resolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]string, string) {
if len(ids) == 0 {
return nil, ""
return nil, nil
}
currentUserID := runtime.UserOpenId()
seen := make(map[string]struct{}, len(ids))
@@ -47,7 +23,7 @@ func resolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]s
for _, id := range ids {
if strings.EqualFold(id, "me") {
if currentUserID == "" {
return nil, fmt.Sprintf("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
return nil, output.ErrValidation("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
}
id = currentUserID
}
@@ -58,5 +34,5 @@ func resolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]s
seen[key] = struct{}{}
out = append(out, id)
}
return out, ""
return out, nil
}

View File

@@ -75,24 +75,3 @@ func TestResolveOpenIDs_DedupIsCaseInsensitive(t *testing.T) {
t.Fatalf("case-insensitive dedup failed: got %v, want [ou_abc123]", out)
}
}
func TestResolveOpenIDsTyped_MeWithoutLogin_ReturnsTypedValidation(t *testing.T) {
rt := resolveOpenIDsTestRuntime("")
_, err := ResolveOpenIDsTyped("--user-ids", []string{"me"}, rt)
validationErr := assertValidationParam(t, err, "--user-ids")
if !strings.Contains(validationErr.Message, "--user-ids") {
t.Fatalf("error should mention the offending flag name; got: %v", err)
}
}
func TestResolveOpenIDsTyped_ExpandsMeAndDedups(t *testing.T) {
rt := resolveOpenIDsTestRuntime("ou_self")
out, err := ResolveOpenIDsTyped("--user-ids", []string{"me", "ou_a", "me", "ou_a"}, rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"ou_self", "ou_a"}
if len(out) != len(want) || out[0] != want[0] || out[1] != want[1] {
t.Fatalf("got %v, want %v", out, want)
}
}

View File

@@ -8,26 +8,16 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
)
// FlagErrorf returns a validation error with flag context (exit code 2).
//
// Deprecated: use ValidationErrorf for typed error envelopes.
func FlagErrorf(format string, args ...any) error {
return output.ErrValidation(format, args...)
}
// ValidationErrorf returns a typed validation error with invalid_argument subtype.
func ValidationErrorf(format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
// MutuallyExclusive checks that at most one of the given flags is set.
//
// Deprecated: use MutuallyExclusiveTyped for typed error envelopes.
func MutuallyExclusive(rt *RuntimeContext, flags ...string) error {
var set []string
for _, f := range flags {
@@ -42,25 +32,7 @@ func MutuallyExclusive(rt *RuntimeContext, flags ...string) error {
return nil
}
// MutuallyExclusiveTyped checks that at most one of the given flags is set.
func MutuallyExclusiveTyped(rt *RuntimeContext, flags ...string) error {
var set []string
for _, f := range flags {
val := rt.Str(f)
if val != "" {
set = append(set, "--"+f)
}
}
if len(set) > 1 {
return ValidationErrorf("%s are mutually exclusive", strings.Join(set, " and ")).
WithParams(invalidParams(set, "mutually exclusive")...)
}
return nil
}
// AtLeastOne checks that at least one of the given flags is set.
//
// Deprecated: use AtLeastOneTyped for typed error envelopes.
func AtLeastOne(rt *RuntimeContext, flags ...string) error {
for _, f := range flags {
if rt.Str(f) != "" {
@@ -74,24 +46,7 @@ func AtLeastOne(rt *RuntimeContext, flags ...string) error {
return FlagErrorf("specify at least one of %s", strings.Join(names, " or "))
}
// AtLeastOneTyped checks that at least one of the given flags is set.
func AtLeastOneTyped(rt *RuntimeContext, flags ...string) error {
for _, f := range flags {
if rt.Str(f) != "" {
return nil
}
}
names := make([]string, len(flags))
for i, f := range flags {
names[i] = "--" + f
}
return ValidationErrorf("specify at least one of %s", strings.Join(names, " or ")).
WithParams(invalidParams(names, "required; specify at least one")...)
}
// ExactlyOne checks that exactly one of the given flags is set.
//
// Deprecated: use ExactlyOneTyped for typed error envelopes.
func ExactlyOne(rt *RuntimeContext, flags ...string) error {
if err := AtLeastOne(rt, flags...); err != nil {
return err
@@ -99,18 +54,8 @@ func ExactlyOne(rt *RuntimeContext, flags ...string) error {
return MutuallyExclusive(rt, flags...)
}
// ExactlyOneTyped checks that exactly one of the given flags is set.
func ExactlyOneTyped(rt *RuntimeContext, flags ...string) error {
if err := AtLeastOneTyped(rt, flags...); err != nil {
return err
}
return MutuallyExclusiveTyped(rt, flags...)
}
// ValidatePageSize validates that the named flag (if set) is an integer within [minVal, maxVal].
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
//
// Deprecated: use ValidatePageSizeTyped for typed error envelopes.
func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
s := rt.Str(flagName)
if s == "" {
@@ -126,25 +71,6 @@ func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, m
return n, nil
}
// ValidatePageSizeTyped validates that the named flag (if set) is an integer within [minVal, maxVal].
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
func ValidatePageSizeTyped(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
s := rt.Str(flagName)
param := "--" + flagName
if s == "" {
return defaultVal, nil
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, ValidationErrorf("invalid --%s %q: must be an integer", flagName, s).WithParam(param)
}
if n < minVal || n > maxVal {
return 0, ValidationErrorf("invalid --%s %d: must be between %d and %d", flagName, n, minVal, maxVal).
WithParam(param)
}
return n, nil
}
// ParseIntBounded parses an int flag and clamps it to [min, max].
func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int {
v := rt.Int(name)
@@ -161,26 +87,13 @@ func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int {
// working directory. It catches traversal, symlink escape, and control
// characters by delegating to FileIO.ResolvePath. Works for both file and
// directory paths.
//
// Deprecated: use ValidateSafePathTyped for typed error envelopes.
func ValidateSafePath(fio fileio.FileIO, path string) error {
_, err := fio.ResolvePath(path)
return err
}
// ValidateSafePathTyped ensures path resolves within the current working directory.
func ValidateSafePathTyped(fio fileio.FileIO, path string) error {
_, err := fio.ResolvePath(path)
if err != nil {
return ValidationErrorf("%s", err).WithCause(err)
}
return nil
}
// RejectDangerousChars returns an error if value contains ASCII control
// characters or dangerous Unicode code points.
//
// Deprecated: use RejectDangerousCharsTyped for typed error envelopes.
func RejectDangerousChars(paramName, value string) error {
for _, r := range value {
if r < 0x20 && r != '\t' && r != '\n' {
@@ -195,31 +108,3 @@ func RejectDangerousChars(paramName, value string) error {
}
return nil
}
// RejectDangerousCharsTyped returns an error if value contains ASCII control
// characters or dangerous Unicode code points.
func RejectDangerousCharsTyped(paramName, value string) error {
for _, r := range value {
if r < 0x20 && r != '\t' && r != '\n' {
return ValidationErrorf("parameter %q contains control character U+%04X", paramName, r).
WithParam(paramName)
}
if r == 0x7F {
return ValidationErrorf("parameter %q contains DEL character", paramName).
WithParam(paramName)
}
if IsDangerousUnicode(r) {
return ValidationErrorf("parameter %q contains dangerous Unicode character U+%04X", paramName, r).
WithParam(paramName)
}
}
return nil
}
func invalidParams(names []string, reason string) []errs.InvalidParam {
params := make([]errs.InvalidParam, len(names))
for i, name := range names {
params[i] = errs.InvalidParam{Name: name, Reason: reason}
}
return params
}

View File

@@ -11,31 +11,10 @@ import (
// ValidateChatID checks if a chat ID has valid format (oc_ prefix).
// Also extracts token from URL if provided.
//
// Deprecated: use ValidateChatIDTyped for typed error envelopes.
func ValidateChatID(input string) (string, error) {
chatID, msg := normalizeChatID(input)
if msg != "" {
return "", output.ErrValidation("%s", msg)
}
return chatID, nil
}
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
// Also extracts token from URL if provided. param names the flag being
// validated (e.g. "--chat-ids") and is recorded on the typed error.
func ValidateChatIDTyped(param, input string) (string, error) {
chatID, msg := normalizeChatID(input)
if msg != "" {
return "", ValidationErrorf("%s", msg).WithParam(param)
}
return chatID, nil
}
func normalizeChatID(input string) (string, string) {
input = strings.TrimSpace(input)
if input == "" {
return "", "chat ID cannot be empty"
return "", output.ErrValidation("chat ID cannot be empty")
}
// Extract from URL if present
if strings.Contains(input, "feishu.cn") || strings.Contains(input, "larksuite.com") {
@@ -49,40 +28,19 @@ func normalizeChatID(input string) (string, string) {
}
}
if !strings.HasPrefix(input, "oc_") {
return "", "invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)"
return "", output.ErrValidation("invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)")
}
return input, ""
return input, nil
}
// ValidateUserID checks if a user ID has valid format (ou_ prefix).
//
// Deprecated: use ValidateUserIDTyped for typed error envelopes.
func ValidateUserID(input string) (string, error) {
userID, msg := normalizeUserID(input)
if msg != "" {
return "", output.ErrValidation("%s", msg)
}
return userID, nil
}
// ValidateUserIDTyped checks if a user ID has valid format (ou_ prefix).
// param names the flag being validated (e.g. "--creator-ids") and is
// recorded on the typed error.
func ValidateUserIDTyped(param, input string) (string, error) {
userID, msg := normalizeUserID(input)
if msg != "" {
return "", ValidationErrorf("%s", msg).WithParam(param)
}
return userID, nil
}
func normalizeUserID(input string) (string, string) {
input = strings.TrimSpace(input)
if input == "" {
return "", "user ID cannot be empty"
return "", output.ErrValidation("user ID cannot be empty")
}
if !strings.HasPrefix(input, "ou_") {
return "", "invalid user ID format, should start with 'ou_' (e.g., ou_abc123)"
return "", output.ErrValidation("invalid user ID format, should start with 'ou_' (e.g., ou_abc123)")
}
return input, ""
return input, nil
}

View File

@@ -4,14 +4,10 @@
package common
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/spf13/cobra"
)
@@ -30,24 +26,6 @@ func newTestRuntime(flags map[string]string) *RuntimeContext {
return &RuntimeContext{Cmd: cmd}
}
func assertValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatal("expected validation error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if param != "" && validationErr.Param != param {
t.Fatalf("Param = %q, want %q", validationErr.Param, param)
}
return validationErr
}
func TestMutuallyExclusive(t *testing.T) {
tests := []struct {
name string
@@ -91,109 +69,6 @@ func TestMutuallyExclusive(t *testing.T) {
}
}
func TestValidationErrorf_ReturnsTypedInvalidArgument(t *testing.T) {
err := ValidationErrorf("bad %s", "flag")
validationErr := assertValidationParam(t, err, "")
if validationErr.Message != "bad flag" {
t.Fatalf("Message = %q, want %q", validationErr.Message, "bad flag")
}
}
func TestTypedFlagGroupHelpers_ReturnValidationParams(t *testing.T) {
t.Run("mutually exclusive", func(t *testing.T) {
rt := newTestRuntime(map[string]string{"a": "x", "b": "y"})
validationErr := assertValidationParam(t, MutuallyExclusiveTyped(rt, "a", "b"), "")
if len(validationErr.Params) != 2 {
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
}
if validationErr.Params[0].Name != "--a" || validationErr.Params[1].Name != "--b" {
t.Fatalf("Params names = %+v, want --a/--b", validationErr.Params)
}
})
t.Run("at least one", func(t *testing.T) {
rt := newTestRuntime(map[string]string{"a": "", "b": ""})
validationErr := assertValidationParam(t, AtLeastOneTyped(rt, "a", "b"), "")
if len(validationErr.Params) != 2 {
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
}
if !strings.Contains(validationErr.Message, "--a or --b") {
t.Fatalf("Message = %q, want flag group", validationErr.Message)
}
})
t.Run("exactly one", func(t *testing.T) {
rt := newTestRuntime(map[string]string{"a": "x", "b": "y"})
validationErr := assertValidationParam(t, ExactlyOneTyped(rt, "a", "b"), "")
if len(validationErr.Params) != 2 {
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
}
})
}
func TestValidatePageSizeTyped_ReturnsTypedValidation(t *testing.T) {
rt := newTestRuntime(map[string]string{"page-size": "nope"})
_, err := ValidatePageSizeTyped(rt, "page-size", 10, 1, 20)
assertValidationParam(t, err, "--page-size")
rt = newTestRuntime(map[string]string{"page-size": "30"})
_, err = ValidatePageSizeTyped(rt, "page-size", 10, 1, 20)
assertValidationParam(t, err, "--page-size")
}
func TestValidateIDTyped_ReturnsTypedValidation(t *testing.T) {
chatID, err := ValidateChatIDTyped("--chat-ids", "https://example.feishu.cn/foo/oc_abc")
if err != nil {
t.Fatalf("ValidateChatIDTyped valid URL: %v", err)
}
if chatID != "oc_abc" {
t.Fatalf("chatID = %q, want oc_abc", chatID)
}
assertValidationParam(t, func() error {
_, err := ValidateChatIDTyped("--chat-ids", "bad")
return err
}(), "--chat-ids")
assertValidationParam(t, func() error {
_, err := ValidateUserIDTyped("--creator-ids", "bad")
return err
}(), "--creator-ids")
}
func TestRejectDangerousCharsTyped_ReturnsTypedValidation(t *testing.T) {
err := RejectDangerousCharsTyped("--query", "bad\x01")
validationErr := assertValidationParam(t, err, "--query")
if !strings.Contains(validationErr.Message, "control character") {
t.Fatalf("Message = %q, want control character", validationErr.Message)
}
}
func TestWrapInputStatErrorTyped_ReturnsTypedValidation(t *testing.T) {
cause := &fileio.PathValidationError{Err: errors.New("outside cwd")}
err := WrapInputStatErrorTyped(cause)
validationErr := assertValidationParam(t, err, "")
if !strings.Contains(validationErr.Message, "unsafe file path") {
t.Fatalf("Message = %q, want unsafe file path", validationErr.Message)
}
if !errors.Is(err, fileio.ErrPathValidation) {
t.Fatalf("expected errors.Is(fileio.ErrPathValidation) to match")
}
}
func TestWrapSaveErrorTyped_ClassifiesPathAndFileIO(t *testing.T) {
pathErr := &fileio.PathValidationError{Err: errors.New("outside cwd")}
assertValidationParam(t, WrapSaveErrorTyped(pathErr), "")
mkdirErr := &fileio.MkdirError{Err: errors.New("permission denied")}
err := WrapSaveErrorTyped(mkdirErr)
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
if internalErr.Subtype != errs.SubtypeFileIO {
t.Fatalf("Subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
}
}
func TestAtLeastOne(t *testing.T) {
tests := []struct {
name string
@@ -371,20 +246,3 @@ func TestValidateSafePath_AllowsNonExistentPath(t *testing.T) {
t.Fatalf("expected no error for non-existent path, got: %v", err)
}
}
// TestValidateSafePathTyped_ReturnsTypedValidation verifies that an escaping
// path is rejected with a typed validation error and a safe path passes.
func TestValidateSafePathTyped_ReturnsTypedValidation(t *testing.T) {
outside := t.TempDir()
workDir := t.TempDir()
chdirForTest(t, workDir)
if err := os.Symlink(outside, filepath.Join(workDir, "evil_out")); err != nil {
t.Fatalf("Symlink: %v", err)
}
assertValidationParam(t, ValidateSafePathTyped(&localfileio.LocalFileIO{}, "evil_out"), "")
if err := ValidateSafePathTyped(&localfileio.LocalFileIO{}, "new_output_dir"); err != nil {
t.Fatalf("expected no error for safe path, got: %v", err)
}
}

View File

@@ -39,296 +39,230 @@ var DriveExport = common.Shortcut{
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return ValidateExport(exportParamsFromFlags(runtime))
return validateDriveExportSpec(driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
},
}
spec := driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
}
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
// ExportParams holds the user-facing inputs for an export flow, decoupled from
// cobra flags so other command groups (e.g. sheets +workbook-export) can reuse
// the drive export implementation. An empty OutputDir means "create the export
// task and poll, but do not download" — callers that only need the ready file
// token / status get it back without writing a local file.
type ExportParams struct {
Token string
DocType string
FileExtension string
SubID string
OutputDir string
FileName string
Overwrite bool
}
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
"file_extension": spec.FileExtension,
}
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
func (p ExportParams) spec() driveExportSpec {
return driveExportSpec{
Token: p.Token,
DocType: p.DocType,
FileExtension: p.FileExtension,
SubID: p.SubID,
}
}
// exportParamsFromFlags reads the standard drive +export flag set.
func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
// drive +export always downloads; an empty --output-dir historically means
// the current directory (saveContentToOutputDir maps "" -> "."), so normalize
// it here to keep behavior identical and stay off the export-only ("" => skip
// download) path that only sheets +workbook-export uses.
outputDir := runtime.Str("output-dir")
if outputDir == "" {
outputDir = "."
}
return ExportParams{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OutputDir: outputDir,
FileName: strings.TrimSpace(runtime.Str("file-name")),
Overwrite: runtime.Bool("overwrite"),
}
}
// ValidateExport runs the CLI-level export constraint checks.
func ValidateExport(p ExportParams) error {
return validateDriveExportSpec(p.spec())
}
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
spec := p.spec()
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(body).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
"file_extension": spec.FileExtension,
}
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
dr := common.NewDryRunAPI().
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(body).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
// RunExport drives create export task -> bounded poll -> optional download. It
// is the shared core behind both drive +export and sheets +workbook-export. An
// empty p.OutputDir skips the download step and returns the ready file token.
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
spec := p.spec()
outputDir := p.OutputDir
preferredFileName := strings.TrimSpace(p.FileName)
overwrite := p.Overwrite
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.CallAPITyped(
"POST",
apiPath,
nil,
map[string]interface{}{
"format": "markdown",
},
)
if err != nil {
return err
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
}
outputDir := runtime.Str("output-dir")
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
overwrite := runtime.Bool("overwrite")
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
}
content, ok := doc["content"].(string)
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
}
fileName := preferredFileName
if fileName == "" {
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.CallAPITyped(
"POST",
apiPath,
nil,
map[string]interface{}{
"format": "markdown",
},
)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
return err
}
fileName = title
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len(content),
}, nil)
return nil
}
ticket, err := createDriveExportTask(runtime, spec)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
var lastStatus driveExportStatus
var lastPollErr error
hasObservedStatus := false
// Keep the command responsive by polling for a bounded window. If the task
// is still running after that, return a resume command instead of blocking.
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(driveExportPollInterval):
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
}
}
if err := ctx.Err(); err != nil {
return err
}
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
if err != nil {
// Treat polling failures as transient so short-lived backend hiccups
// do not immediately fail an otherwise healthy export task.
lastPollErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
continue
}
lastStatus = status
hasObservedStatus = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
// Export-only mode: caller wants the ready file token / metadata but
// no local download (e.g. sheets +workbook-export without an output
// path). Skip the download and return the status envelope.
if strings.TrimSpace(outputDir) == "" {
runtime.Out(map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_token": status.FileToken,
"file_name": status.FileName,
"file_size": status.FileSize,
"ready": true,
"downloaded": false,
}, nil)
return nil
content, ok := doc["content"].(string)
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
}
fileName := preferredFileName
if fileName == "" {
fileName = status.FileName
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
}
fileName = title
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
if err != nil {
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
hint := fmt.Sprintf(
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
ticket,
status.FileToken,
recoveryCommand,
)
return appendDriveExportRecoveryHint(err, hint)
return err
}
out["ticket"] = ticket
out["doc_type"] = spec.DocType
out["file_extension"] = spec.FileExtension
runtime.Out(out, nil)
runtime.Out(map[string]interface{}{
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len(content),
}, nil)
return nil
}
if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
ticket, err := createDriveExportTask(runtime, spec)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
var lastStatus driveExportStatus
var lastPollErr error
hasObservedStatus := false
// Keep the command responsive by polling for a bounded window. If the task
// is still running after that, return a resume command instead of blocking.
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(driveExportPollInterval):
}
}
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
if err := ctx.Err(); err != nil {
return err
}
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
if err != nil {
// Treat polling failures as transient so short-lived backend hiccups
// do not immediately fail an otherwise healthy export task.
lastPollErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
continue
}
lastStatus = status
hasObservedStatus = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
fileName := preferredFileName
if fileName == "" {
fileName = status.FileName
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
if err != nil {
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
hint := fmt.Sprintf(
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
ticket,
status.FileToken,
recoveryCommand,
)
return appendDriveExportRecoveryHint(err, hint)
}
out["ticket"] = ticket
out["doc_type"] = spec.DocType
out["file_extension"] = spec.FileExtension
runtime.Out(out, nil)
return nil
}
if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
}
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
}
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
}
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
if !hasObservedStatus && lastPollErr != nil {
hint := fmt.Sprintf(
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
ticket,
nextCommand,
)
return appendDriveExportRecoveryHint(lastPollErr, hint)
}
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
if !hasObservedStatus && lastPollErr != nil {
hint := fmt.Sprintf(
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
ticket,
nextCommand,
)
return appendDriveExportRecoveryHint(lastPollErr, hint)
}
failed := false
var jobStatus interface{}
jobStatusLabel := "unknown"
if hasObservedStatus {
failed = lastStatus.Failed()
jobStatus = lastStatus.JobStatus
jobStatusLabel = lastStatus.StatusLabel()
}
// Return the last observed status so callers can resume from a known task
// state instead of losing all progress information on timeout.
result := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"ready": false,
"failed": failed,
"job_status": jobStatus,
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(result, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
failed := false
var jobStatus interface{}
jobStatusLabel := "unknown"
if hasObservedStatus {
failed = lastStatus.Failed()
jobStatus = lastStatus.JobStatus
jobStatusLabel = lastStatus.StatusLabel()
}
// Return the last observed status so callers can resume from a known task
// state instead of losing all progress information on timeout.
result := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"ready": false,
"failed": failed,
"job_status": jobStatus,
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(result, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
},
}

View File

@@ -488,72 +488,6 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
}
}
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
// explicit empty --output-dir must still download to the current directory
// (normalized to "."), not trigger the export-only no-download path that the
// shared RunExport core uses for sheets +workbook-export.
func TestDriveExportEmptyOutputDirDownloadsToCwd(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"ticket": "tk_e"}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_e",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0, "file_token": "box_e", "file_name": "report",
"file_extension": "pdf", "type": "docx", "file_size": 3,
},
}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_e/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--output-dir", "",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Empty --output-dir must still write to cwd, not skip the download.
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
if err != nil {
t.Fatalf("empty --output-dir should still download to cwd: %v", err)
}
if string(data) != "pdf" {
t.Fatalf("downloaded content = %q", string(data))
}
if strings.Contains(stdout.String(), `"downloaded": false`) {
t.Fatalf("export-only path must not trigger for drive +export: %s", stdout.String())
}
}
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{

View File

@@ -34,160 +34,128 @@ var DriveImport = common.Shortcut{
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return ValidateImport(importParamsFromFlags(runtime))
return validateDriveImportSpec(driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
spec := driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if valErr := validateDriveImportSpec(spec); valErr != nil {
return common.NewDryRunAPI().Set("error", valErr.Error())
}
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
appendDriveImportUploadDryRun(dry, spec, fileSize)
dry.POST("/open-apis/drive/v1/import_tasks").
Desc("[2] Create import task").
Body(spec.CreateTaskBody("<file_token>"))
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
Desc("[3] Poll import task result").
Set("ticket", "<ticket>")
if runtime.IsBot() {
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
spec := driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err
}
// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
if uploadErr != nil {
return uploadErr
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
// Step 2: Create import task
ticket, err := createDriveImportTask(runtime, spec, fileToken)
if err != nil {
return err
}
// Step 3: Poll task
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
status, ready, err := pollDriveImportTask(runtime, ticket)
if err != nil {
return err
}
// Some intermediate responses omit the final type, so fall back to the
// requested type to keep the output shape stable.
resultType := status.DocType
if resultType == "" {
resultType = spec.DocType
}
out := map[string]interface{}{
"ticket": ticket,
"type": resultType,
"ready": ready,
"job_status": status.JobStatus,
"job_status_label": status.StatusLabel(),
}
if status.Token != "" {
out["token"] = status.Token
}
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
out["url"] = statusURL
} else if status.Token != "" {
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
out["url"] = u
}
}
if status.JobErrorMsg != "" {
out["job_error_msg"] = status.JobErrorMsg
}
if status.Extra != nil {
out["extra"] = status.Extra
}
if !ready {
nextCommand := driveImportTaskResultCommand(ticket)
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
if ready {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
out["permission_grant"] = grant
}
}
runtime.Out(out, nil)
return nil
},
}
// ImportParams holds the user-facing inputs for an import flow, decoupled from
// cobra flags so other command groups (e.g. sheets +workbook-import) can reuse
// the drive import implementation without taking a dependency on a --type flag.
type ImportParams struct {
File string
DocType string
FolderToken string
Name string
TargetToken string
}
func (p ImportParams) spec() driveImportSpec {
return driveImportSpec{
FilePath: p.File,
DocType: strings.ToLower(p.DocType),
FolderToken: p.FolderToken,
Name: p.Name,
TargetToken: p.TargetToken,
}
}
// importParamsFromFlags reads the standard drive +import flag set.
func importParamsFromFlags(runtime *common.RuntimeContext) ImportParams {
return ImportParams{
File: runtime.Str("file"),
DocType: runtime.Str("type"),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
}
// ValidateImport runs the CLI-level compatibility checks for an import.
func ValidateImport(p ImportParams) error {
return validateDriveImportSpec(p.spec())
}
// PlanImportDryRun builds the dry-run plan (upload -> create task -> poll) for
// an import without performing any network or file I/O beyond a local stat.
func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.DryRunAPI {
spec := p.spec()
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if valErr := validateDriveImportSpec(spec); valErr != nil {
return common.NewDryRunAPI().Set("error", valErr.Error())
}
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
appendDriveImportUploadDryRun(dry, spec, fileSize)
dry.POST("/open-apis/drive/v1/import_tasks").
Desc("[2] Create import task").
Body(spec.CreateTaskBody("<file_token>"))
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
Desc("[3] Poll import task result").
Set("ticket", "<ticket>")
if runtime.IsBot() {
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
}
return dry
}
// RunImport executes the full import flow: upload media -> create import task ->
// bounded poll, then writes the result envelope to the runtime output. It is
// the shared core behind both drive +import and sheets +workbook-import.
func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportParams) error {
spec := p.spec()
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err
}
// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
if uploadErr != nil {
return uploadErr
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
// Step 2: Create import task
ticket, err := createDriveImportTask(runtime, spec, fileToken)
if err != nil {
return err
}
// Step 3: Poll task
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
status, ready, err := pollDriveImportTask(runtime, ticket)
if err != nil {
return err
}
// Some intermediate responses omit the final type, so fall back to the
// requested type to keep the output shape stable.
resultType := status.DocType
if resultType == "" {
resultType = spec.DocType
}
out := map[string]interface{}{
"ticket": ticket,
"type": resultType,
"ready": ready,
"job_status": status.JobStatus,
"job_status_label": status.StatusLabel(),
}
if status.Token != "" {
out["token"] = status.Token
}
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
out["url"] = statusURL
} else if status.Token != "" {
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
out["url"] = u
}
}
if status.JobErrorMsg != "" {
out["job_error_msg"] = status.JobErrorMsg
}
if status.Extra != nil {
out["extra"] = status.Extra
}
if !ready {
nextCommand := driveImportTaskResultCommand(ticket)
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
if ready {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
out["permission_grant"] = grant
}
}
runtime.Out(out, nil)
return nil
}
func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) {
// Keep dry-run and execution aligned on path normalization, file existence,
// and format-specific size limits before planning the upload path.

View File

@@ -354,7 +354,7 @@ func parseDriveSearchPageSize(raw string) (int, error) {
// server-side failure or empty result.
func validateDriveSearchIDs(spec driveSearchSpec) error {
for _, id := range spec.CreatorIDs {
if _, err := common.ValidateUserIDTyped("--creator-ids", id); err != nil {
if _, err := common.ValidateUserID(id); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--creator-ids %q: %s", id, err).WithParam("--creator-ids")
}
}
@@ -362,7 +362,7 @@ func validateDriveSearchIDs(spec driveSearchSpec) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n).WithParam("--chat-ids")
}
for _, id := range spec.ChatIDs {
if _, err := common.ValidateChatIDTyped("--chat-ids", id); err != nil {
if _, err := common.ValidateChatID(id); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids %q: %s", id, err).WithParam("--chat-ids")
}
}
@@ -370,7 +370,7 @@ func validateDriveSearchIDs(spec driveSearchSpec) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n).WithParam("--sharer-ids")
}
for _, id := range spec.SharerIDs {
if _, err := common.ValidateUserIDTyped("--sharer-ids", id); err != nil {
if _, err := common.ValidateUserID(id); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids %q: %s", id, err).WithParam("--sharer-ids")
}
}

View File

@@ -7,7 +7,6 @@ import (
"encoding/json"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
@@ -197,12 +196,8 @@ func (c *cardConverter) convert(jsonCard string, hintSchema int) string {
header, _ := card["header"].(cardObj)
title := ""
subtitle := ""
headerTags := ""
if header != nil {
title = c.extractHeaderTitle(header)
subtitle = c.extractHeaderSubtitle(header)
headerTags = c.extractHeaderTags(header)
}
bodyContent := ""
@@ -211,19 +206,13 @@ func (c *cardConverter) convert(jsonCard string, hintSchema int) string {
}
var sb strings.Builder
if title != "" && subtitle != "" {
sb.WriteString(fmt.Sprintf("<card title=\"%s\" subtitle=\"%s\">\n", cardEscapeAttr(title), cardEscapeAttr(subtitle)))
} else if title != "" {
sb.WriteString(fmt.Sprintf("<card title=\"%s\">\n", cardEscapeAttr(title)))
} else if subtitle != "" {
sb.WriteString(fmt.Sprintf("<card subtitle=\"%s\">\n", cardEscapeAttr(subtitle)))
if title != "" {
sb.WriteString("<card title=\"")
sb.WriteString(cardEscapeAttr(title))
sb.WriteString("\">\n")
} else {
sb.WriteString("<card>\n")
}
if headerTags != "" {
sb.WriteString(headerTags)
sb.WriteString("\n")
}
if bodyContent != "" {
sb.WriteString(bodyContent)
sb.WriteString("\n")
@@ -244,49 +233,6 @@ func (c *cardConverter) extractHeaderTitle(header cardObj) string {
return ""
}
// extractHeaderSubtitle returns the subtitle text of a card header, supporting both
// the property-wrapped and flat element formats.
func (c *cardConverter) extractHeaderSubtitle(header cardObj) string {
if prop, ok := header["property"].(cardObj); ok {
if subtitleElem, ok := prop["subtitle"]; ok {
return c.extractTextContent(subtitleElem)
}
}
if subtitleElem, ok := header["subtitle"]; ok {
return c.extractTextContent(subtitleElem)
}
return ""
}
// extractHeaderTags returns a space-joined string of header tag labels from textTagList,
// supporting both property-wrapped and flat header formats.
func (c *cardConverter) extractHeaderTags(header cardObj) string {
var prop cardObj
if p, ok := header["property"].(cardObj); ok {
prop = p
} else {
prop = header
}
tagList, ok := prop["textTagList"].([]interface{})
if !ok || len(tagList) == 0 {
return ""
}
var tags []string
for _, tag := range tagList {
tm, ok := tag.(cardObj)
if !ok {
continue
}
if text := c.convertElement(tm, 0); text != "" {
tags = append(tags, text)
}
}
if len(tags) == 0 {
return ""
}
return strings.Join(tags, " ")
}
func (c *cardConverter) convertBody(body cardObj) string {
var elements []interface{}
@@ -533,11 +479,8 @@ func (c *cardConverter) convertDiv(prop cardObj, _ string) string {
if textElem, ok := prop["text"].(cardObj); ok {
if text := c.convertElement(textElem, 0); text != "" {
textProp := c.extractProperty(textElem)
if textStyle, ok := textProp["textStyle"].(cardObj); ok {
if size, _ := textStyle["size"].(string); size == "notation" {
text = "📝 " + text
}
if textSize, _ := textElem["text_size"].(string); textSize == "notation" {
text = "📝 " + text
}
results = append(results, text)
}
@@ -615,14 +558,7 @@ func (c *cardConverter) convertEmoji(prop cardObj) string {
}
func (c *cardConverter) convertLocalDatetime(prop cardObj) string {
var ms string
switch v := prop["milliseconds"].(type) {
case string:
ms = v
case float64:
ms = strconv.FormatInt(int64(v), 10)
}
if ms != "" {
if ms, ok := prop["milliseconds"].(string); ok && ms != "" {
if formatted := cardFormatMillisToISO8601(ms); formatted != "" {
return formatted
}
@@ -853,22 +789,22 @@ func (c *cardConverter) convertCollapsiblePanel(prop cardObj, _ string) string {
}
}
indicator := "▶"
if expanded {
indicator = "▼"
}
var sb strings.Builder
sb.WriteString(indicator + " " + title + "\n")
if elements, ok := prop["elements"].([]interface{}); ok {
content := c.convertElements(elements, 1)
for _, line := range strings.Split(content, "\n") {
if line != "" {
sb.WriteString(" " + line + "\n")
shouldExpand := expanded || c.mode == cardModeDetailed
if shouldExpand {
var sb strings.Builder
sb.WriteString("▼ " + title + "\n")
if elements, ok := prop["elements"].([]interface{}); ok {
content := c.convertElements(elements, 1)
for _, line := range strings.Split(content, "\n") {
if line != "" {
sb.WriteString(" " + line + "\n")
}
}
}
sb.WriteString("▲")
return sb.String()
}
sb.WriteString("▲")
return sb.String()
return "▶ " + title
}
func (c *cardConverter) convertInteractiveContainer(prop cardObj, id string) string {
@@ -916,17 +852,10 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string {
}
disabled, _ := prop["disabled"].(bool)
if disabled {
result := fmt.Sprintf("[%s ✗]", buttonText)
if tips, ok := prop["disabledTips"].(cardObj); ok {
if tipsText := c.extractTextContent(tips); tipsText != "" {
result += fmt.Sprintf("(tips:\"%s\")", tipsText)
}
}
return result
if disabled && c.mode == cardModeConcise {
return fmt.Sprintf("[%s ✗]", buttonText)
}
result := fmt.Sprintf("[%s]", buttonText)
if actions, ok := prop["actions"].([]interface{}); ok {
for _, action := range actions {
am, ok := action.(cardObj)
@@ -936,32 +865,24 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string {
if am["type"] == "open_url" {
if ad, ok := am["action"].(cardObj); ok {
if urlStr, ok := ad["url"].(string); ok && urlStr != "" {
result = fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr)
break
return fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr)
}
}
}
}
}
if confirmObj, ok := prop["confirm"].(cardObj); ok {
var parts []string
if titleElem, ok := confirmObj["title"]; ok {
if t := c.extractTextContent(titleElem); t != "" {
parts = append(parts, t)
if disabled && c.mode == cardModeDetailed {
result := fmt.Sprintf("[%s ✗]", buttonText)
if tips, ok := prop["disabledTips"].(cardObj); ok {
if tipsText := c.extractTextContent(tips); tipsText != "" {
result += fmt.Sprintf("(tips:\"%s\")", tipsText)
}
}
if textElem, ok := confirmObj["text"]; ok {
if t := c.extractTextContent(textElem); t != "" {
parts = append(parts, t)
}
}
if len(parts) > 0 {
result += fmt.Sprintf("(confirm:\"%s\")", strings.Join(parts, ": "))
}
return result
}
return result
return fmt.Sprintf("[%s]", buttonText)
}
func (c *cardConverter) convertActions(prop cardObj) string {
@@ -993,33 +914,11 @@ func (c *cardConverter) convertOverflow(prop cardObj) string {
if !ok {
continue
}
text := ""
if textElem, ok := om["text"].(cardObj); ok {
text = c.extractTextContent(textElem)
}
if text == "" {
continue
}
urlStr := ""
if actions, ok := om["actions"].([]interface{}); ok {
for _, a := range actions {
am, ok := a.(cardObj)
if !ok {
continue
}
if am["type"] == "open_url" {
if ad, ok := am["action"].(cardObj); ok {
urlStr, _ = ad["url"].(string)
}
}
if text := c.extractTextContent(textElem); text != "" {
optTexts = append(optTexts, text)
}
}
if urlStr != "" {
text = fmt.Sprintf("[%s](%s)", escapeMDLinkText(text), urlStr)
} else if value, _ := om["value"].(string); value != "" {
text += "(" + value + ")"
}
optTexts = append(optTexts, text)
}
return "⋮ " + strings.Join(optTexts, ", ")
}
@@ -1059,20 +958,17 @@ func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) str
if !ok {
continue
}
value, _ := om["value"].(string)
optText := ""
if textElem, ok := om["text"].(cardObj); ok {
optText = c.extractTextContent(textElem)
}
if optText == "" {
optText = c.lookupOptionUserName(value)
}
if optText == "" {
optText = value
optText, _ = om["value"].(string)
}
if optText == "" {
continue
}
value, _ := om["value"].(string)
if selectedValues[value] {
optText = "✓" + optText
hasSelected = true
@@ -1093,15 +989,17 @@ func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) str
}
result := "{" + strings.Join(optionTexts, " / ") + "}"
var attrs []string
if isMulti {
attrs = append(attrs, "multi")
}
if c.mode == cardModeDetailed && strings.Contains(id, "person") {
attrs = append(attrs, "type:person")
}
if len(attrs) > 0 {
result += "(" + strings.Join(attrs, " ") + ")"
if c.mode == cardModeDetailed {
var attrs []string
if isMulti {
attrs = append(attrs, "multi")
}
if strings.Contains(id, "person") {
attrs = append(attrs, "type:person")
}
if len(attrs) > 0 {
result += "(" + strings.Join(attrs, " ") + ")"
}
}
return result
}
@@ -1127,17 +1025,6 @@ func (c *cardConverter) convertSelectImg(prop cardObj, _ string) string {
}
value, _ := om["value"].(string)
text := fmt.Sprintf("🖼️ Image %d", i+1)
if value != "" {
text += "(" + value + ")"
}
if imageID, ok := om["imageID"].(string); ok && imageID != "" {
originKey, imgToken := c.getImageKeyAndToken(imageID)
if originKey != "" {
text += "(img_key:" + originKey + ")"
} else if imgToken != "" {
text += "(img_token:" + imgToken + ")"
}
}
if selectedValues[value] {
text = "✓" + text
}
@@ -1240,14 +1127,13 @@ func (c *cardConverter) convertImage(prop cardObj, _ string) string {
}
result := "🖼️ " + alt
if imageID, ok := prop["imageID"].(string); ok && imageID != "" {
originKey, imgToken := c.getImageKeyAndToken(imageID)
if originKey != "" {
result += "(img_key:" + originKey + ")"
} else if imgToken != "" {
result += "(img_token:" + imgToken + ")"
} else {
result += "(img_key:" + imageID + ")"
if c.mode == cardModeDetailed {
if imageID, ok := prop["imageID"].(string); ok && imageID != "" {
if token := c.getImageToken(imageID); token != "" {
result += "(img_token:" + token + ")"
} else {
result += "(img_key:" + imageID + ")"
}
}
}
return result
@@ -1259,25 +1145,20 @@ func (c *cardConverter) convertImgCombination(prop cardObj) string {
return ""
}
result := fmt.Sprintf("🖼️ %d image(s)", len(imgList))
var keys []string
for _, img := range imgList {
im, ok := img.(cardObj)
if !ok {
continue
}
if imageID, ok := im["imageID"].(string); ok && imageID != "" {
originKey, imgToken := c.getImageKeyAndToken(imageID)
if originKey != "" {
keys = append(keys, originKey)
} else if imgToken != "" {
keys = append(keys, imgToken)
} else {
if c.mode == cardModeDetailed {
var keys []string
for _, img := range imgList {
im, ok := img.(cardObj)
if !ok {
continue
}
if imageID, ok := im["imageID"].(string); ok && imageID != "" {
keys = append(keys, imageID)
}
}
}
if len(keys) > 0 {
result += "(keys:" + strings.Join(keys, ",") + ")"
if len(keys) > 0 {
result += "(keys:" + strings.Join(keys, ",") + ")"
}
}
return result
}
@@ -1295,11 +1176,7 @@ func (c *cardConverter) convertChart(prop cardObj, _ string) string {
if ct, ok := chartSpec["type"].(string); ok && ct != "" {
chartType = ct
if typeName, ok := cardChartTypeNames[ct]; ok {
if title != "Chart" {
title += " (" + typeName + ")"
} else {
title = typeName
}
title += typeName
}
}
}
@@ -1317,25 +1194,12 @@ func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) stri
if !ok {
return ""
}
// VChart spec: data is an array of series objects ([{"id":"...","values":[...]}]).
// Older/object format: data is a map with a "values" key directly.
var values []interface{}
switch d := chartSpec["data"].(type) {
case cardObj:
if v, ok := d["values"].([]interface{}); ok {
values = v
}
case []interface{}:
for _, series := range d {
if sm, ok := series.(cardObj); ok {
if v, ok := sm["values"].([]interface{}); ok {
values = append(values, v...)
}
}
}
dataObj, ok := chartSpec["data"].(cardObj)
if !ok {
return ""
}
if len(values) == 0 {
values, ok := dataObj["values"].([]interface{})
if !ok || len(values) == 0 {
return ""
}
@@ -1380,24 +1244,28 @@ func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) stri
func (c *cardConverter) convertAudio(prop cardObj, _ string) string {
result := "🎵 Audio"
fileID, _ := prop["fileID"].(string)
if fileID == "" {
fileID, _ = prop["audioID"].(string)
}
if fileID != "" {
result += "(key:" + fileID + ")"
if c.mode == cardModeDetailed {
fileID, _ := prop["fileID"].(string)
if fileID == "" {
fileID, _ = prop["audioID"].(string)
}
if fileID != "" {
result += "(key:" + fileID + ")"
}
}
return result
}
func (c *cardConverter) convertVideo(prop cardObj, _ string) string {
result := "🎬 Video"
fileID, _ := prop["fileID"].(string)
if fileID == "" {
fileID, _ = prop["videoID"].(string)
}
if fileID != "" {
result += "(key:" + fileID + ")"
if c.mode == cardModeDetailed {
fileID, _ := prop["fileID"].(string)
if fileID == "" {
fileID, _ = prop["videoID"].(string)
}
if fileID != "" {
result += "(key:" + fileID + ")"
}
}
return result
}
@@ -1455,14 +1323,9 @@ func (c *cardConverter) convertTable(prop cardObj) string {
func (c *cardConverter) extractTableCellValue(data interface{}) string {
switch v := data.(type) {
case string:
// Lark API serialises array-type cell data as a Go-format string like
// "[map[text:VIP] map[text:Premium]]". Detect and extract text values.
if texts := goMapArrayTexts(v); len(texts) > 0 {
return strings.Join(texts, ", ")
}
return v
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
return strconv.FormatFloat(v, 'f', 2, 64)
case []interface{}:
var texts []string
for _, item := range v {
@@ -1483,47 +1346,6 @@ func (c *cardConverter) extractTableCellValue(data interface{}) string {
}
}
// goMapNextKey matches the start of the next key in a Go fmt map literal (space + identifier + colon).
var goMapNextKey = regexp.MustCompile(` [a-zA-Z_][a-zA-Z0-9_]*:`)
// goMapArrayTexts extracts "text" values from a Go-format slice-of-maps string,
// e.g. "[map[text:VIP] map[text:Premium]]" → ["VIP", "Premium"].
// Values may contain spaces; they are delimited by the next map key or by "]".
// Returns nil if the string doesn't look like this format.
func goMapArrayTexts(s string) []string {
if !strings.HasPrefix(s, "[") || !strings.Contains(s, "map[") {
return nil
}
const key = "text:"
var texts []string
rest := s
for {
idx := strings.Index(rest, key)
if idx < 0 {
break
}
after := rest[idx+len(key):]
bracketEnd := strings.Index(after, "]")
nextKey := goMapNextKey.FindStringIndex(after)
var end int
if nextKey != nil && (bracketEnd < 0 || nextKey[0] < bracketEnd) {
end = nextKey[0]
} else if bracketEnd >= 0 {
end = bracketEnd
} else {
if after != "" {
texts = append(texts, after)
}
break
}
if val := after[:end]; val != "" {
texts = append(texts, val)
}
rest = after[end:]
}
return texts
}
func (c *cardConverter) convertPerson(prop cardObj, _ string) string {
userID, _ := prop["userID"].(string)
if userID == "" {
@@ -1537,14 +1359,14 @@ func (c *cardConverter) convertPerson(prop cardObj, _ string) string {
}
if personName != "" {
if c.mode == cardModeDetailed {
return fmt.Sprintf("%s(open_id:%s)", personName, userID)
return fmt.Sprintf("@%s(open_id:%s)", personName, userID)
}
return personName
return "@" + personName
}
if c.mode == cardModeDetailed {
return fmt.Sprintf("user(open_id:%s)", userID)
return fmt.Sprintf("@user(open_id:%s)", userID)
}
return userID
return "@" + userID
}
// convertPersonV1 handles the v1 card schema person element.
@@ -1560,14 +1382,14 @@ func (c *cardConverter) convertPersonV1(prop cardObj, _ string) string {
personName := c.lookupPersonName(userID)
if personName != "" {
if c.mode == cardModeDetailed {
return fmt.Sprintf("%s(open_id:%s)", personName, userID)
return fmt.Sprintf("@%s(open_id:%s)", personName, userID)
}
return personName
return "@" + personName
}
if c.mode == cardModeDetailed {
return fmt.Sprintf("user(open_id:%s)", userID)
return fmt.Sprintf("@user(open_id:%s)", userID)
}
return userID
return "@" + userID
}
func (c *cardConverter) convertPersonList(prop cardObj) string {
@@ -1582,21 +1404,10 @@ func (c *cardConverter) convertPersonList(prop cardObj) string {
continue
}
personID, _ := pm["id"].(string)
personName := c.lookupPersonName(personID)
if personName != "" {
if c.mode == cardModeDetailed {
names = append(names, fmt.Sprintf("%s(open_id:%s)", personName, personID))
} else {
names = append(names, personName)
}
} else if personID != "" {
if c.mode == cardModeDetailed {
names = append(names, fmt.Sprintf("user(id:%s)", personID))
} else {
names = append(names, personID)
}
if c.mode == cardModeDetailed && personID != "" {
names = append(names, fmt.Sprintf("@user(id:%s)", personID))
} else {
names = append(names, "user")
names = append(names, "@user")
}
}
return strings.Join(names, ", ")
@@ -1604,15 +1415,8 @@ func (c *cardConverter) convertPersonList(prop cardObj) string {
func (c *cardConverter) convertAvatar(prop cardObj, _ string) string {
userID, _ := prop["userID"].(string)
personName := c.lookupPersonName(userID)
if personName != "" {
if c.mode == cardModeDetailed {
return fmt.Sprintf("👤 %s(open_id:%s)", personName, userID)
}
return "👤 " + personName
}
result := "👤"
if userID != "" {
if c.mode == cardModeDetailed && userID != "" {
result += "(id:" + userID + ")"
}
return result
@@ -1693,37 +1497,20 @@ func (c *cardConverter) lookupPersonName(userID string) string {
return ""
}
// lookupOptionUserName resolves a user display name from the attachment's option_users map,
// used for person-selector option labels.
func (c *cardConverter) lookupOptionUserName(userID string) string {
func (c *cardConverter) getImageToken(imageID string) string {
if c.attachment == nil {
return ""
}
if optUsers, ok := c.attachment["option_users"].(cardObj); ok {
if userInfo, ok := optUsers[userID].(cardObj); ok {
if content, ok := userInfo["content"].(string); ok {
return content
if images, ok := c.attachment["images"].(cardObj); ok {
if imageInfo, ok := images[imageID].(cardObj); ok {
if token, ok := imageInfo["token"].(string); ok {
return token
}
}
}
return ""
}
// getImageKeyAndToken returns the origin_key and token for an image ID from the attachment map.
// origin_key takes priority over token as the display-ready image reference.
func (c *cardConverter) getImageKeyAndToken(imageID string) (originKey, token string) {
if c.attachment == nil {
return "", ""
}
if images, ok := c.attachment["images"].(cardObj); ok {
if imageInfo, ok := images[imageID].(cardObj); ok {
originKey, _ = imageInfo["origin_key"].(string)
token, _ = imageInfo["token"].(string)
}
}
return originKey, token
}
type cardTextStyle struct {
bold bool
italic bool

File diff suppressed because it is too large Load Diff

View File

@@ -2620,45 +2620,3 @@ func validateBotMailboxNotMe(runtime *common.RuntimeContext) error {
}
return nil
}
// validateMessageIDs parses and validates the existing +messages comma-separated
// flag format. Unlike splitByComma, it keeps empty entries so "id1,,id2" fails
// locally. It intentionally does not enforce the server-side single-call limit:
// fetchFullMessages chunks backend requests into batches of 20.
func validateMessageIDs(raw string) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return nil, output.ErrValidation("--message-ids is required; provide one or more message IDs separated by commas")
}
parts := strings.Split(raw, ",")
ids := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for i, part := range parts {
id := strings.TrimSpace(part)
if id == "" {
return nil, output.ErrValidation("--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
if part != id {
return nil, output.ErrValidation("--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
}
if err := validateBatchGetMessageID(id, i); err != nil {
return nil, err
}
if _, ok := seen[id]; ok {
return nil, output.ErrValidation("--message-ids entry %d (%q): duplicate message ID is not allowed", i+1, id)
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids, nil
}
func validateBatchGetMessageID(id string, index int) error {
if strings.Trim(id, "0123456789") == "" {
return output.ErrValidation("--message-ids entry %d (%q): numeric primary IDs are not supported by mail +messages; pass the Open API message_id from mail output", index+1, id)
}
decoded, err := base64.URLEncoding.DecodeString(id)
if err != nil || len(decoded) == 0 {
return output.ErrValidation("--message-ids entry %d (%q): expected a base64url Open API mail message_id from mail output", index+1, id)
}
return nil
}

View File

@@ -6,6 +6,7 @@ package mail
import (
"context"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -18,8 +19,7 @@ type mailMessagesOutput struct {
}
// MailMessages is the `+messages` shortcut: batch-fetch full content for
// multiple message IDs, chunking backend calls into batches of 20 while
// preserving request order.
// up to 20 message IDs in a single call, preserving request order.
var MailMessages = common.Shortcut{
Service: "mail",
Command: "+messages",
@@ -35,15 +35,11 @@ var MailMessages = common.Shortcut{
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateBotMailboxNotMe(runtime); err != nil {
return err
}
_, err := validateMessageIDs(runtime.Str("message-ids"))
return err
return validateBotMailboxNotMe(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(runtime)
messageIDs, _ := validateMessageIDs(runtime.Str("message-ids"))
messageIDs := splitByComma(runtime.Str("message-ids"))
body := map[string]interface{}{
"format": messageGetFormat(runtime.Bool("html")),
"message_ids": []string{"<message_id_1>", "<message_id_2>"},
@@ -63,9 +59,9 @@ var MailMessages = common.Shortcut{
}
mailboxID := resolveMailboxID(runtime)
hintIdentityFirst(runtime, mailboxID)
messageIDs, err := validateMessageIDs(runtime.Str("message-ids"))
if err != nil {
return err
messageIDs := splitByComma(runtime.Str("message-ids"))
if len(messageIDs) == 0 {
return output.ErrValidation("--message-ids is required; provide one or more message IDs separated by commas")
}
html := runtime.Bool("html")

View File

@@ -1,92 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/base64"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestMailMessagesExecuteChunksMoreThanTwentyIDs(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
ids := make([]string, 21)
for i := range ids {
ids[i] = base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("biz-%03d", i)))
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/batch_get",
BodyFilter: requestMessageIDsEqual(ids[:20]),
Body: batchGetMessagesResponse(ids[:20]),
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/batch_get",
BodyFilter: requestMessageIDsEqual(ids[20:]),
Body: batchGetMessagesResponse(ids[20:]),
})
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--message-ids", strings.Join(ids, ","),
}, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
out := decodeShortcutEnvelopeData(t, stdout)
if got := int(out["total"].(float64)); got != len(ids) {
t.Fatalf("total = %d, want %d; stdout=%s", got, len(ids), stdout.String())
}
messages, ok := out["messages"].([]interface{})
if !ok {
t.Fatalf("messages has unexpected type %T", out["messages"])
}
if len(messages) != len(ids) {
t.Fatalf("messages length = %d, want %d", len(messages), len(ids))
}
for i, item := range messages {
msg, ok := item.(map[string]interface{})
if !ok {
t.Fatalf("messages[%d] has unexpected type %T", i, item)
}
if got := msg["message_id"]; got != ids[i] {
t.Fatalf("messages[%d].message_id = %v, want %s", i, got, ids[i])
}
}
}
func requestMessageIDsEqual(want []string) func([]byte) bool {
return func(body []byte) bool {
var payload struct {
MessageIDs []string `json:"message_ids"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return false
}
return reflect.DeepEqual(payload.MessageIDs, want)
}
}
func batchGetMessagesResponse(ids []string) map[string]interface{} {
messages := make([]map[string]interface{}, 0, len(ids))
for _, id := range ids {
messages = append(messages, map[string]interface{}{
"message_id": id,
"subject": id,
})
}
return map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"messages": messages,
},
}
}

View File

@@ -4,7 +4,6 @@
package mail
import (
"encoding/base64"
"os"
"strings"
"testing"
@@ -134,7 +133,7 @@ func TestMailMessageUserMailboxMePassesValidation(t *testing.T) {
func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--as", "bot", "--message-ids", validMessageIDForTest("biz-x"),
"+messages", "--as", "bot", "--message-ids", "msg_xxx",
}, f, stdout)
assertValidationError(t, err, "does not support --mailbox me")
}
@@ -143,7 +142,7 @@ func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
func TestMailMessagesBotExplicitMailboxPassesValidation(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", validMessageIDForTest("biz-x"),
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", "msg_xxx",
}, f, stdout)
assertValidatePasses(t, err)
}
@@ -183,87 +182,3 @@ func TestMailTriageBotExplicitMailboxPassesValidation(t *testing.T) {
}, f, stdout)
assertValidatePasses(t, err)
}
// --- message_ids validation tests (S2) ---
func validMessageIDForTest(s string) string {
return base64.URLEncoding.EncodeToString([]byte(s))
}
func TestValidateMessageIDsAcceptsValidIDs(t *testing.T) {
_, err := validateMessageIDs(validMessageIDForTest("biz-001") + "," + validMessageIDForTest("biz-002"))
if err != nil {
t.Fatalf("expected nil error for valid IDs, got: %v", err)
}
}
func TestValidateMessageIDsRejectsEmpty(t *testing.T) {
_, err := validateMessageIDs("")
assertValidationError(t, err, "--message-ids is required")
_, err = validateMessageIDs(" ")
assertValidationError(t, err, "--message-ids is required")
}
func TestValidateMessageIDsAcceptsMoreThanSingleBackendBatch(t *testing.T) {
ids := make([]string, 21)
for i := range ids {
ids[i] = validMessageIDForTest(string(rune('a' + i)))
}
_, err := validateMessageIDs(strings.Join(ids, ","))
if err != nil {
t.Fatalf("expected nil error for more than one backend batch, got: %v", err)
}
}
func TestValidateMessageIDsRejectsEmptyEntry(t *testing.T) {
_, err := validateMessageIDs(validMessageIDForTest("biz-1") + ",," + validMessageIDForTest("biz-2"))
assertValidationError(t, err, "entry 2 is empty")
}
func TestValidateMessageIDsRejectsLeadingOrTrailingWhitespace(t *testing.T) {
id1 := validMessageIDForTest("biz-1")
id2 := validMessageIDForTest("biz-2")
_, err := validateMessageIDs(id1 + ", " + id2)
assertValidationError(t, err, "must not contain leading or trailing whitespace")
_, err = validateMessageIDs(" " + id1 + "," + id2)
assertValidationError(t, err, "must not contain leading or trailing whitespace")
}
func TestValidateMessageIDsRejectsDuplicateIDs(t *testing.T) {
id := validMessageIDForTest("biz-1")
_, err := validateMessageIDs(id + "," + id)
assertValidationError(t, err, "duplicate message ID is not allowed")
}
func TestValidateMessageIDsRejectsJSONLikeInput(t *testing.T) {
_, err := validateMessageIDs(`["id1","id2"]`)
assertValidationError(t, err, "expected a base64url")
}
func TestValidateMessageIDsRejectsColonJoinedInput(t *testing.T) {
_, err := validateMessageIDs("id1:id2")
assertValidationError(t, err, "expected a base64url")
}
func TestValidateMessageIDsRejectsNumericPrimaryID(t *testing.T) {
_, err := validateMessageIDs("123456789")
assertValidationError(t, err, "numeric primary IDs are not supported")
}
func TestValidateMessageIDsAcceptsExactlyTwenty(t *testing.T) {
ids := make([]string, 20)
for i := range ids {
ids[i] = validMessageIDForTest(string(rune('A' + i)))
}
_, err := validateMessageIDs(strings.Join(ids, ","))
if err != nil {
t.Fatalf("expected nil error for exactly 20 IDs, got: %v", err)
}
}
func TestValidateMessageIDRejectsInvalidBase64(t *testing.T) {
_, err := validateMessageIDs("msg 1")
assertValidationError(t, err, "expected a base64url")
_, err = validateMessageIDs("not-base64!")
assertValidationError(t, err, "expected a base64url")
}

View File

@@ -14,7 +14,6 @@ import (
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/apps"
@@ -30,7 +29,6 @@ import (
"github.com/larksuite/cli/shortcuts/markdown"
"github.com/larksuite/cli/shortcuts/minutes"
"github.com/larksuite/cli/shortcuts/sheets"
sheetsbackward "github.com/larksuite/cli/shortcuts/sheets/backward"
"github.com/larksuite/cli/shortcuts/slides"
"github.com/larksuite/cli/shortcuts/task"
"github.com/larksuite/cli/shortcuts/vc"
@@ -66,11 +64,6 @@ func init() {
allShortcuts = append(allShortcuts, im.Shortcuts()...)
allShortcuts = append(allShortcuts, contact_shortcuts.Shortcuts()...)
allShortcuts = append(allShortcuts, sheets.Shortcuts()...)
// Backward-compatible sheets shortcuts (pre-refactor command names),
// kept under shortcuts/sheets/backward so external callers relying on the
// old `+create`, `+read`, `+write`, ... commands keep working alongside the
// refactored ones. Command names are disjoint from sheets.Shortcuts().
allShortcuts = append(allShortcuts, wrapSheetsBackwardDeprecation(sheetsbackward.Shortcuts())...)
allShortcuts = append(allShortcuts, base.Shortcuts()...)
allShortcuts = append(allShortcuts, event.Shortcuts()...)
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
@@ -153,9 +146,6 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
if service == "mail" {
mail.InstallOnMail(svc)
}
if service == "sheets" {
applySheetsCompatGroups(svc)
}
if !IsShortcutServiceAvailable(service, brand) {
installBrandRestrictionGuard(svc, service, brand)
@@ -199,153 +189,3 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core
// --help bypasses RunE, so surface the restriction in Long too.
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
}
// Sheets backward-compatibility help grouping.
//
// shortcuts/sheets/backward keeps the pre-refactor command names alive so that
// users whose lark-sheets skill predates the refactor keep working even after
// upgrading only the binary. In `sheets --help` those aliases would otherwise
// sort alphabetically into the same flat list as the current commands,
// indistinguishable from them. applySheetsCompatGroups splits them into a
// dedicated cobra group whose heading tells the user to update their skill, and
// appends a "(→ +new-command)" pointer to each alias so the migration target is
// obvious. Pure presentation — the aliases stay fully executable.
const (
sheetsCurrentGroupID = "sheets-current"
// sheetsDeprecatedGroupID aliases the shared deprecated-group id so both
// `sheets --help` grouping and the generic unknown-subcommand path
// (cmd/root.go) classify these aliases the same way.
sheetsDeprecatedGroupID = cmdutil.DeprecatedGroupID
)
// sheetsAliasReplacement maps each pre-refactor sheets alias to the current
// command(s) that replace it, shown as a "(→ ...)" suffix in --help. Aliases
// absent from this map still land in the deprecated group, just without a
// pointer, so a missing entry degrades gracefully rather than misgrouping.
var sheetsAliasReplacement = map[string]string{
// spreadsheet / sheet management
"+create": "+workbook-create",
"+info": "+workbook-info",
"+export": "+workbook-export",
"+create-sheet": "+sheet-create",
"+copy-sheet": "+sheet-copy",
"+delete-sheet": "+sheet-delete",
"+update-sheet": "+sheet-rename / +sheet-move / …",
// cell data
"+read": "+cells-get",
"+write": "+cells-set",
"+append": "+cells-set",
"+find": "+cells-search",
"+replace": "+cells-replace",
// cell style / merge / image
"+set-style": "+cells-set-style",
"+batch-set-style": "+cells-batch-set-style",
"+merge-cells": "+cells-merge",
"+unmerge-cells": "+cells-unmerge",
"+write-image": "+cells-set-image",
// row / column dimensions
"+add-dimension": "+dim-insert",
"+insert-dimension": "+dim-insert",
"+update-dimension": "+rows-resize / +dim-hide / …",
"+move-dimension": "+dim-move",
"+delete-dimension": "+dim-delete",
// filter views (conditions folded into the view flags)
"+create-filter-view": "+filter-view-create",
"+update-filter-view": "+filter-view-update",
"+list-filter-views": "+filter-view-list",
"+get-filter-view": "+filter-view-list",
"+delete-filter-view": "+filter-view-delete",
"+create-filter-view-condition": "+filter-view-update",
"+update-filter-view-condition": "+filter-view-update",
"+list-filter-view-conditions": "+filter-view-list",
"+get-filter-view-condition": "+filter-view-list",
"+delete-filter-view-condition": "+filter-view-update",
// dropdowns
"+set-dropdown": "+dropdown-set",
"+update-dropdown": "+dropdown-update",
"+get-dropdown": "+dropdown-get",
"+delete-dropdown": "+dropdown-delete",
// float images (media-upload folded into create)
"+media-upload": "+float-image-create",
"+create-float-image": "+float-image-create",
"+update-float-image": "+float-image-update",
"+get-float-image": "+float-image-list",
"+list-float-images": "+float-image-list",
"+delete-float-image": "+float-image-delete",
}
func applySheetsCompatGroups(svc *cobra.Command) {
svc.AddGroup(
&cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"},
&cobra.Group{
ID: sheetsDeprecatedGroupID,
Title: "Deprecated pre-refactor commands (still work) — update your lark-sheets skill, then: lark-cli update",
},
)
deprecated := make(map[string]struct{})
for _, s := range sheetsbackward.Shortcuts() {
deprecated[s.Command] = struct{}{}
}
for _, c := range svc.Commands() {
name := c.Name()
if _, ok := deprecated[name]; ok {
c.GroupID = sheetsDeprecatedGroupID
if repl := sheetsAliasReplacement[name]; repl != "" {
c.Short = c.Short + " (→ " + repl + ")"
}
continue
}
// Only the refactored shortcuts (all "+"-prefixed) belong in the current
// group. Leave the OpenAPI metaapi subcommands (spreadsheets, ...) and the
// auto-added help/completion ungrouped so cobra files them under
// "Additional Commands".
if len(name) > 0 && name[0] == '+' {
c.GroupID = sheetsCurrentGroupID
}
}
}
// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets
// alias so that invoking it records a process-level deprecation notice, which
// cmd/root.go surfaces in the JSON "_notice" envelope. This reaches the users
// the --help grouping cannot: those whose pre-refactor skill calls +read /
// +write directly and never reads --help. Replacement targets come from
// sheetsAliasReplacement — the same single source of truth that drives the
// "(→ +new)" help pointers.
func wrapSheetsBackwardDeprecation(list []common.Shortcut) []common.Shortcut {
for i := range list {
notice := &deprecation.Notice{
Command: list[i].Command,
Replacement: sheetsAliasReplacement[list[i].Command],
Skill: "lark-sheets",
}
// Record the notice as soon as the command's own logic runs, so it is
// surfaced even when Validate rejects the call — an out-of-date skill
// can pass pre-refactor argument shapes (e.g. a range without the new
// sheet-id prefix) and fail validation before Execute — and when
// --dry-run short-circuits before Execute. Both hooks store the same
// pointer, so setting it twice is harmless.
if origValidate := list[i].Validate; origValidate != nil {
list[i].Validate = func(ctx context.Context, runtime *common.RuntimeContext) error {
deprecation.SetPending(notice)
return origValidate(ctx, runtime)
}
}
if origExecute := list[i].Execute; origExecute != nil {
list[i].Execute = func(ctx context.Context, runtime *common.RuntimeContext) error {
deprecation.SetPending(notice)
return origExecute(ctx, runtime)
}
}
// The Validate/Execute wrappers above miss one path: a cobra-level
// required flag (MarkFlagRequired) that is absent fails at
// ValidateRequiredFlags, before RunE — so neither hook runs and the
// notice would be lost on exactly the "stale skill calls the old command
// and mis-supplies flags" case it exists for. OnInvoke runs from PreRunE,
// ahead of ValidateRequiredFlags, so the notice still surfaces there.
list[i].OnInvoke = func() { deprecation.SetPending(notice) }
}
return list
}

View File

@@ -5,7 +5,6 @@ package shortcuts
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -17,9 +16,7 @@ import (
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -474,152 +471,3 @@ func TestGenerateShortcutsJSON(t *testing.T) {
}
t.Logf("wrote %d bytes to %s", len(data), output)
}
// applySheetsCompatGroups must split the sheets service into a current group
// (refactored "+"-shortcuts) and a deprecated group (backward-compat aliases),
// append a "(→ +new)" migration pointer to each alias, and leave non-"+"
// subcommands (OpenAPI metaapi, help/completion) ungrouped so cobra files them
// under "Additional Commands".
func TestApplySheetsCompatGroups(t *testing.T) {
svc := &cobra.Command{Use: "sheets"}
newCmd := &cobra.Command{Use: "+cells-get", Short: "Read ranges"}
aliasCmd := &cobra.Command{Use: "+read", Short: "Read spreadsheet cell values"}
metaCmd := &cobra.Command{Use: "spreadsheets", Short: "spreadsheets operations"}
svc.AddCommand(newCmd, aliasCmd, metaCmd)
applySheetsCompatGroups(svc)
if !svc.ContainsGroup(sheetsCurrentGroupID) {
t.Errorf("current group %q not registered", sheetsCurrentGroupID)
}
if !svc.ContainsGroup(sheetsDeprecatedGroupID) {
t.Errorf("deprecated group %q not registered", sheetsDeprecatedGroupID)
}
if newCmd.GroupID != sheetsCurrentGroupID {
t.Errorf("+cells-get GroupID = %q, want %q", newCmd.GroupID, sheetsCurrentGroupID)
}
if aliasCmd.GroupID != sheetsDeprecatedGroupID {
t.Errorf("+read GroupID = %q, want %q", aliasCmd.GroupID, sheetsDeprecatedGroupID)
}
if !strings.Contains(aliasCmd.Short, "(→ +cells-get)") {
t.Errorf("+read Short missing migration pointer, got %q", aliasCmd.Short)
}
if metaCmd.GroupID != "" {
t.Errorf("metaapi spreadsheets should stay ungrouped, got GroupID %q", metaCmd.GroupID)
}
}
// End-to-end: the rendered `sheets --help` must surface the deprecated-group
// heading (telling users to update their skill) plus the per-alias migration
// pointers, while keeping the refactored shortcuts under Available Commands.
func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
sheetsCmd, _, err := program.Find([]string{"sheets"})
if err != nil {
t.Fatalf("find sheets command: %v", err)
}
var out bytes.Buffer
sheetsCmd.SetOut(&out)
if err := sheetsCmd.Help(); err != nil {
t.Fatalf("sheets help failed: %v", err)
}
got := out.String()
for _, want := range []string{
"Available Commands:",
"Deprecated pre-refactor commands",
"update your lark-sheets skill",
"+read",
"(→ +cells-get)",
"+write",
"(→ +cells-set)",
} {
if !strings.Contains(got, want) {
t.Fatalf("sheets help missing %q:\n%s", want, got)
}
}
}
// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that
// invoking it records a process-level deprecation notice (reusing
// sheetsAliasReplacement for the migration target) while still calling the
// original Execute. cmd/root.go reads that notice into the JSON "_notice".
func TestWrapSheetsBackwardDeprecation(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
called := false
in := []common.Shortcut{{
Service: "sheets",
Command: "+read",
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
called = true
return nil
},
}}
out := wrapSheetsBackwardDeprecation(in)
if len(out) != 1 {
t.Fatalf("wrapped list len = %d, want 1", len(out))
}
if deprecation.GetPending() != nil {
t.Fatal("notice set before wrapped Execute ran")
}
if err := out[0].Execute(context.Background(), nil); err != nil {
t.Fatalf("wrapped Execute returned error: %v", err)
}
if !called {
t.Fatal("original Execute was not invoked by the wrapper")
}
dep := deprecation.GetPending()
if dep == nil {
t.Fatal("expected a pending deprecation notice after Execute")
}
if dep.Command != "+read" {
t.Errorf("notice Command = %q, want +read", dep.Command)
}
if dep.Replacement != "+cells-get" {
t.Errorf("notice Replacement = %q, want +cells-get (from sheetsAliasReplacement)", dep.Replacement)
}
if dep.Skill != "lark-sheets" {
t.Errorf("notice Skill = %q, want lark-sheets", dep.Skill)
}
}
// The wrapper must also decorate Validate, so an out-of-date skill whose
// pre-refactor argument shape fails validation (before Execute) still gets the
// deprecation notice in its error envelope.
func TestWrapSheetsBackwardDeprecationValidateHook(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
validated := false
in := []common.Shortcut{{
Service: "sheets",
Command: "+write",
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
validated = true
return nil
},
}}
out := wrapSheetsBackwardDeprecation(in)
if out[0].Validate == nil {
t.Fatal("Validate hook was dropped by the wrapper")
}
if err := out[0].Validate(context.Background(), nil); err != nil {
t.Fatalf("wrapped Validate returned error: %v", err)
}
if !validated {
t.Fatal("original Validate was not invoked")
}
dep := deprecation.GetPending()
if dep == nil || dep.Command != "+write" || dep.Replacement != "+cells-set" {
t.Fatalf("Validate hook did not record expected notice: %#v", dep)
}
}

View File

@@ -1,239 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var (
singleCellRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*$`)
cellSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+[1-9][0-9]*$`)
cellToColRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+$`)
colSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+:[A-Za-z]+$`)
rowSpanRangePattern = regexp.MustCompile(`^[1-9][0-9]*:[1-9][0-9]*$`)
cellRefPattern = regexp.MustCompile(`^([A-Za-z]+)([1-9][0-9]*)$`)
)
var sheetRangeSeparatorReplacer = strings.NewReplacer(`\`, "!", `\!`, "!", "", "!")
// getFirstSheetID queries the spreadsheet and returns the first sheet's ID.
func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) {
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
if err != nil {
return "", err
}
sheets, _ := data["sheets"].([]interface{})
if len(sheets) > 0 {
sheet, _ := sheets[0].(map[string]interface{})
if id, ok := sheet["sheet_id"].(string); ok && id != "" {
return id, nil
}
}
return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet")
}
// extractSpreadsheetToken extracts spreadsheet token from URL.
func extractSpreadsheetToken(input string) string {
input = strings.TrimSpace(input)
prefixes := []string{"/sheets/", "/spreadsheets/"}
for _, prefix := range prefixes {
if idx := strings.Index(input, prefix); idx >= 0 {
token := input[idx+len(prefix):]
if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 {
token = token[:idx2]
}
return token
}
}
return input
}
func normalizeSheetRange(sheetID, input string) string {
input = normalizeSheetRangeSeparators(input)
if input == "" || strings.Contains(input, "!") || sheetID == "" {
return input
}
if looksLikeRelativeRange(input) {
return sheetID + "!" + input
}
return input
}
func normalizePointRange(sheetID, input string) string {
input = normalizeSheetRange(sheetID, input)
if input == "" {
return input
}
rangeSheetID, subRange, ok := splitSheetRange(input)
if !ok || !singleCellRangePattern.MatchString(subRange) {
return input
}
return rangeSheetID + "!" + subRange + ":" + subRange
}
func normalizeWriteRange(sheetID, input string, values interface{}) string {
rows, cols := matrixDimensions(values)
input = normalizeSheetRangeSeparators(input)
if input == "" {
return buildRectRange(sheetID, "A1", rows, cols)
}
input = normalizeSheetRange(sheetID, input)
rangeSheetID, subRange, ok := splitSheetRange(input)
if !ok {
return buildRectRange(input, "A1", rows, cols)
}
if singleCellRangePattern.MatchString(subRange) {
return buildRectRange(rangeSheetID, subRange, rows, cols)
}
return input
}
func validateSheetRangeInput(sheetID, input string) error {
input = normalizeSheetRangeSeparators(input)
if input == "" || strings.Contains(input, "!") || sheetID != "" {
return nil
}
if looksLikeRelativeRange(input) {
return common.FlagErrorf("--range %q requires --sheet-id or a <sheetId>! prefix", input)
}
return nil
}
// validateSingleCellRange rejects multi-cell spans (e.g. "A1:B2") that are
// invalid for single-cell operations like write-image. Empty and single-cell
// values pass through.
func validateSingleCellRange(input string) error {
input = normalizeSheetRangeSeparators(input)
if input == "" {
return nil
}
// Extract the sub-range after the sheet ID prefix, if present.
subRange := input
if _, sr, ok := splitSheetRange(input); ok {
subRange = sr
}
if cellSpanRangePattern.MatchString(subRange) {
parts := strings.SplitN(subRange, ":", 2)
if strings.EqualFold(parts[0], parts[1]) {
return nil
}
return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input)
}
return nil
}
func looksLikeRelativeRange(input string) bool {
input = normalizeSheetRangeSeparators(input)
if input == "" {
return false
}
return singleCellRangePattern.MatchString(input) ||
cellSpanRangePattern.MatchString(input) ||
cellToColRangePattern.MatchString(input) ||
colSpanRangePattern.MatchString(input) ||
rowSpanRangePattern.MatchString(input)
}
func splitSheetRange(input string) (sheetID, subRange string, ok bool) {
parts := strings.SplitN(normalizeSheetRangeSeparators(input), "!", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", false
}
return parts[0], parts[1], true
}
func normalizeSheetRangeSeparators(input string) string {
input = strings.TrimSpace(input)
if input == "" {
return input
}
return sheetRangeSeparatorReplacer.Replace(input)
}
func buildRectRange(sheetID, anchor string, rows, cols int) string {
if sheetID == "" {
return ""
}
if rows < 1 {
rows = 1
}
if cols < 1 {
cols = 1
}
endCell, err := offsetCell(anchor, rows-1, cols-1)
if err != nil {
return sheetID
}
return sheetID + "!" + anchor + ":" + endCell
}
func matrixDimensions(values interface{}) (rows, cols int) {
rowList, ok := values.([]interface{})
if !ok || len(rowList) == 0 {
return 1, 1
}
rows = len(rowList)
for _, row := range rowList {
if cells, ok := row.([]interface{}); ok && len(cells) > cols {
cols = len(cells)
}
}
if cols == 0 {
cols = 1
}
return rows, cols
}
func offsetCell(cell string, rowOffset, colOffset int) (string, error) {
matches := cellRefPattern.FindStringSubmatch(strings.TrimSpace(cell))
if len(matches) != 3 {
return "", fmt.Errorf("invalid cell reference: %s", cell)
}
colIndex := columnNameToIndex(matches[1])
if colIndex < 1 {
return "", fmt.Errorf("invalid column: %s", matches[1])
}
rowIndex, err := strconv.Atoi(matches[2])
if err != nil {
return "", err
}
return fmt.Sprintf("%s%d", columnIndexToName(colIndex+colOffset), rowIndex+rowOffset), nil
}
func columnNameToIndex(name string) int {
name = strings.ToUpper(strings.TrimSpace(name))
if name == "" {
return 0
}
index := 0
for _, r := range name {
if r < 'A' || r > 'Z' {
return 0
}
index = index*26 + int(r-'A'+1)
}
return index
}
func columnIndexToName(index int) string {
if index < 1 {
return ""
}
var out []byte
for index > 0 {
index--
out = append([]byte{byte('A' + index%26)}, out...)
index /= 26
}
return string(out)
}

View File

@@ -1,71 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all sheets shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
// Spreadsheet management
SheetCreate,
SheetInfo,
SheetExport,
// Sheet management
SheetCreateSheet,
SheetCopySheet,
SheetDeleteSheet,
SheetUpdateSheet,
// Cell data
SheetRead,
SheetWrite,
SheetAppend,
SheetFind,
SheetReplace,
// Cell style and merge
SheetSetStyle,
SheetBatchSetStyle,
SheetMergeCells,
SheetUnmergeCells,
// Cell images
SheetWriteImage,
// Row/column management
SheetAddDimension,
SheetInsertDimension,
SheetUpdateDimension,
SheetMoveDimension,
SheetDeleteDimension,
// Filter views
SheetCreateFilterView,
SheetUpdateFilterView,
SheetListFilterViews,
SheetGetFilterView,
SheetDeleteFilterView,
SheetCreateFilterViewCondition,
SheetUpdateFilterViewCondition,
SheetListFilterViewConditions,
SheetGetFilterViewCondition,
SheetDeleteFilterViewCondition,
// Dropdown
SheetSetDropdown,
SheetUpdateDropdown,
SheetGetDropdown,
SheetDeleteDropdown,
// Float images
SheetMediaUpload,
SheetCreateFloatImage,
SheetUpdateFloatImage,
SheetGetFloatImage,
SheetListFloatImages,
SheetDeleteFloatImage,
}
}

View File

@@ -1,919 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
// TestBatchOp_BodyMatchesStandalone is the core contract: for every batchable
// shortcut, the MCP body produced inside +batch-update must be byte-for-byte
// identical to the body the same shortcut produces when invoked standalone
// (both observed via --dry-run, comparing tool_name + decoded input). This is
// what guarantees "a sub-op behaves exactly like the standalone command", and
// it is the regression guard for the whole flag→body translator reuse.
//
// Each case provides the standalone CLI args and the equivalent sub-op input
// object (same CLI flag names, minus the spreadsheet locator which the batch
// supplies at the top level).
func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
t.Parallel()
cases := []struct {
shortcut string
sc common.Shortcut
// standalone args (excluding --url, which every case shares)
args []string
// sub-op input object as JSON (CLI flag names; no excel_id/url)
subInput string
}{
{
shortcut: "+cells-set",
sc: CellsSet,
args: []string{"--sheet-id", "sh1", "--range", "A1:B1", "--cells", `[[{"value":"x"},{"value":"y"}]]`},
subInput: `{"sheet-id":"sh1","range":"A1:B1","cells":[[{"value":"x"},{"value":"y"}]]}`,
},
{
shortcut: "+cells-clear",
sc: CellsClear,
args: []string{"--sheet-id", "sh1", "--range", "A1:C3", "--scope", "formats"},
subInput: `{"sheet-id":"sh1","range":"A1:C3","scope":"formats"}`,
},
{
shortcut: "+cells-replace",
sc: CellsReplace,
args: []string{"--sheet-id", "sh1", "--find", "foo", "--replacement", "bar", "--match-case"},
subInput: `{"sheet-id":"sh1","find":"foo","replacement":"bar","match-case":true}`,
},
{
shortcut: "+csv-put",
sc: CsvPut,
args: []string{"--sheet-id", "sh1", "--csv", "a,b\n1,2", "--start-cell", "B2"},
subInput: `{"sheet-id":"sh1","csv":"a,b\n1,2","start-cell":"B2"}`,
},
{
shortcut: "+cells-merge",
sc: CellsMerge,
args: []string{"--sheet-id", "sh1", "--range", "A1:C1", "--merge-type", "rows"},
subInput: `{"sheet-id":"sh1","range":"A1:C1","merge-type":"rows"}`,
},
{
shortcut: "+cells-unmerge",
sc: CellsUnmerge,
args: []string{"--sheet-id", "sh1", "--range", "A1:C1"},
subInput: `{"sheet-id":"sh1","range":"A1:C1"}`,
},
{
shortcut: "+dim-insert",
sc: DimInsert,
args: []string{"--sheet-id", "sh1", "--position", "11", "--count", "2", "--inherit-style", "before"},
subInput: `{"sheet-id":"sh1","position":"11","count":2,"inherit-style":"before"}`,
},
{
shortcut: "+dim-delete",
sc: DimDelete,
args: []string{"--sheet-id", "sh1", "--range", "C:D"},
subInput: `{"sheet-id":"sh1","range":"C:D"}`,
},
{
shortcut: "+dim-hide",
sc: DimHide,
args: []string{"--sheet-id", "sh1", "--range", "2:3"},
subInput: `{"sheet-id":"sh1","range":"2:3"}`,
},
{
shortcut: "+dim-freeze",
sc: DimFreeze,
args: []string{"--sheet-id", "sh1", "--dimension", "row", "--count", "2"},
subInput: `{"sheet-id":"sh1","dimension":"row","count":2}`,
},
{
shortcut: "+dim-group",
sc: DimGroup,
args: []string{"--sheet-id", "sh1", "--range", "2:5", "--group-state", "fold"},
subInput: `{"sheet-id":"sh1","range":"2:5","group-state":"fold"}`,
},
{
shortcut: "+rows-resize",
sc: RowsResize,
args: []string{"--sheet-id", "sh1", "--range", "1", "--type", "pixel", "--size", "30"},
subInput: `{"sheet-id":"sh1","range":"1","type":"pixel","size":30}`,
},
{
shortcut: "+cols-resize",
sc: ColsResize,
args: []string{"--sheet-id", "sh1", "--range", "B:D", "--type", "standard"},
subInput: `{"sheet-id":"sh1","range":"B:D","type":"standard"}`,
},
{
shortcut: "+range-move",
sc: RangeMove,
args: []string{"--sheet-id", "sh1", "--source-range", "A1:C5", "--target-range", "D1"},
subInput: `{"sheet-id":"sh1","source-range":"A1:C5","target-range":"D1"}`,
},
{
shortcut: "+range-copy",
sc: RangeCopy,
args: []string{"--sheet-id", "sh1", "--source-range", "A1:B2", "--target-range", "A10", "--paste-type", "values"},
subInput: `{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"values"}`,
},
{
shortcut: "+range-fill",
sc: RangeFill,
args: []string{"--sheet-id", "sh1", "--source-range", "A1:A2", "--target-range", "A1:A10", "--series-type", "linear"},
subInput: `{"sheet-id":"sh1","source-range":"A1:A2","target-range":"A1:A10","series-type":"linear"}`,
},
{
shortcut: "+range-sort",
sc: RangeSort,
args: []string{"--sheet-id", "sh1", "--range", "A1:D10", "--sort-keys", `[{"column":"B","ascending":true}]`, "--has-header"},
subInput: `{"sheet-id":"sh1","range":"A1:D10","sort-keys":[{"column":"B","ascending":true}],"has-header":true}`,
},
{
shortcut: "+sheet-create",
sc: SheetCreate,
args: []string{"--title", "New", "--index", "2"},
subInput: `{"title":"New","index":2}`,
},
{
shortcut: "+sheet-delete",
sc: SheetDelete,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+sheet-rename",
sc: SheetRename,
args: []string{"--sheet-id", "sh1", "--title", "Renamed"},
subInput: `{"sheet-id":"sh1","title":"Renamed"}`,
},
{
shortcut: "+sheet-copy",
sc: SheetCopy,
args: []string{"--sheet-id", "sh1", "--title", "Copy"},
subInput: `{"sheet-id":"sh1","title":"Copy"}`,
},
{
shortcut: "+sheet-hide",
sc: SheetHide,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+sheet-unhide",
sc: SheetUnhide,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+sheet-set-tab-color",
sc: SheetSetTabColor,
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
},
{
shortcut: "+sheet-show-gridline",
sc: SheetShowGridline,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+sheet-hide-gridline",
sc: SheetHideGridline,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+dropdown-set",
sc: DropdownSet,
args: []string{"--sheet-id", "sh1", "--range", "A2:A4", "--options", `["x","y"]`, "--multiple"},
subInput: `{"sheet-id":"sh1","range":"A2:A4","options":["x","y"],"multiple":true}`,
},
{
// --highlight=false explicitly opts out of the server's new
// enable_highlight=true default. Covers the tri-state Changed()
// branch in buildDropdownValidation: standalone reads the cobra
// "Changed" bit; sub-op reads the key's presence in the map.
shortcut: "+dropdown-set",
sc: DropdownSet,
args: []string{"--sheet-id", "sh1", "--range", "A2:A4", "--options", `["x","y"]`, "--highlight=false"},
subInput: `{"sheet-id":"sh1","range":"A2:A4","options":["x","y"],"highlight":false}`,
},
{
shortcut: "+chart-create",
sc: ChartCreate,
args: []string{"--sheet-id", "sh1", "--properties", `{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`},
subInput: `{"sheet-id":"sh1","properties":{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}}`,
},
{
shortcut: "+chart-update",
sc: ChartUpdate,
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--properties", `{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`},
subInput: `{"sheet-id":"sh1","chart-id":"c1","properties":{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}}`,
},
{
shortcut: "+chart-delete",
sc: ChartDelete,
args: []string{"--sheet-id", "sh1", "--chart-id", "c1"},
subInput: `{"sheet-id":"sh1","chart-id":"c1"}`,
},
{
shortcut: "+pivot-create",
sc: PivotCreate,
// +pivot-create renamed --sheet-id / --sheet-name → --target-sheet-id /
// --target-sheet-name to flag the placement-sheet semantics (the data
// source is in --source). Both standalone args and the +batch-update
// sub-op input must use the new names.
args: []string{"--target-sheet-id", "sh1", "--properties", `{"rows":[]}`, "--source", "Sheet1!A1:D100"},
subInput: `{"target-sheet-id":"sh1","properties":{"rows":[]},"source":"Sheet1!A1:D100"}`,
},
{
shortcut: "+cond-format-create",
sc: CondFormatCreate,
args: []string{"--sheet-id", "sh1", "--properties", `{"style":{}}`, "--rule-type", "duplicateValues", "--ranges", `["A1:A100"]`},
subInput: `{"sheet-id":"sh1","properties":{"style":{}},"rule-type":"duplicateValues","ranges":["A1:A100"]}`,
},
{
shortcut: "+filter-create",
sc: FilterCreate,
args: []string{"--sheet-id", "sh1", "--range", "A1:F1000", "--properties", `{"rules":[]}`},
subInput: `{"sheet-id":"sh1","range":"A1:F1000","properties":{"rules":[]}}`,
},
{
shortcut: "+filter-update",
sc: FilterUpdate,
args: []string{"--sheet-id", "sh1", "--range", "A1:F1000", "--properties", `{"rules":[]}`},
subInput: `{"sheet-id":"sh1","range":"A1:F1000","properties":{"rules":[]}}`,
},
{
shortcut: "+filter-delete",
sc: FilterDelete,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+filter-view-create",
sc: FilterViewCreate,
args: []string{"--sheet-id", "sh1", "--range", "A1:Z100", "--view-name", "v1", "--properties", `{"rules":[]}`},
subInput: `{"sheet-id":"sh1","range":"A1:Z100","view-name":"v1","properties":{"rules":[]}}`,
},
{
shortcut: "+sparkline-create",
sc: SparklineCreate,
args: []string{"--sheet-id", "sh1", "--properties", `{"type":"line","data_range":"A2:F2","target_range":"G2"}`},
subInput: `{"sheet-id":"sh1","properties":{"type":"line","data_range":"A2:F2","target_range":"G2"}}`,
},
{
shortcut: "+sparkline-delete",
sc: SparklineDelete,
args: []string{"--sheet-id", "sh1", "--group-id", "g1"},
subInput: `{"sheet-id":"sh1","group-id":"g1"}`,
},
{
shortcut: "+float-image-create",
sc: FloatImageCreate,
args: []string{"--sheet-id", "sh1", "--image-name", "logo.png", "--image-token", "tok", "--position-row", "0", "--position-col", "A", "--size-width", "100", "--size-height", "50"},
subInput: `{"sheet-id":"sh1","image-name":"logo.png","image-token":"tok","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
},
{
shortcut: "+float-image-delete",
sc: FloatImageDelete,
args: []string{"--sheet-id", "sh1", "--float-image-id", "fi1"},
subInput: `{"sheet-id":"sh1","float-image-id":"fi1"}`,
},
}
for _, tc := range cases {
t.Run(tc.shortcut, func(t *testing.T) {
t.Parallel()
mapping, ok := batchOpDispatch[tc.shortcut]
if !ok {
t.Fatalf("%s not in batchOpDispatch", tc.shortcut)
}
// Standalone body via the shortcut's own dry-run.
standaloneBody := decodeToolInput(t, parseDryRunBody(t, tc.sc, append([]string{"--url", testURL}, tc.args...)), mapping.mcpToolName)
// Batch body via the +batch-update translator.
var subInput map[string]interface{}
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
t.Fatalf("bad subInput JSON: %v", err)
}
fv := newMapFlagViewForCommand(tc.shortcut, subInput)
// Match what translateBatchOp does — read the sheet selector
// via the shortcut-specific flag names so +pivot-create
// (target-sheet-id / target-sheet-name) and the rest
// (sheet-id / sheet-name) both resolve correctly.
sidFlag, snameFlag := sheetSelectorFlagsForSubOp(tc.shortcut)
sidStr, _ := subInput[sidFlag].(string)
snameStr, _ := subInput[snameFlag].(string)
batchBody, err := mapping.translate(fv, testToken, sidStr, snameStr)
if err != nil {
t.Fatalf("batch translate failed: %v", err)
}
// Round-trip the batch body through JSON so number types match the
// standalone path (which is decoded from a JSON string).
batchBody = jsonRoundTrip(t, batchBody)
if !reflect.DeepEqual(standaloneBody, batchBody) {
t.Errorf("%s: batch body != standalone body\n standalone=%#v\n batch =%#v", tc.shortcut, standaloneBody, batchBody)
}
})
}
}
func jsonRoundTrip(t *testing.T, m map[string]interface{}) map[string]interface{} {
t.Helper()
b, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(b, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return out
}
// TestBatchOp_ErrorEquivalence is the second half of the contract: for the
// same bad input, the standalone shortcut Validate and the +batch-update
// sub-op translator must emit the same friendly CLI error. Previously a
// sub-op that omitted --sheet-id (or another required flag) slipped through
// to the server and surfaced as "sheet undefined not found"; with the
// validation pushed down into the xxxInput builders both paths now stop the
// request before the API call.
//
// Scope: this test covers checks that cobra cannot enforce — XOR pairs
// (sheet selector, image token/uri), range relationships, enum-bound rules,
// pixel/size cross-flag coupling. cobra's own MarkFlagRequired catches the
// single-required cases on the standalone path with its own
// "required flag(s) \"X\" not set" wording; the batch path now catches the
// same situations with our friendlier "--X is required" wording — those are
// asserted by TestBatchOp_RejectsBadSubOpInput below.
func TestBatchOp_ErrorEquivalence(t *testing.T) {
t.Parallel()
cases := []struct {
name string
// shortcut & standalone args. --url is supplied by the runner. Args
// satisfy every cobra-required flag so cobra doesn't short-circuit
// before our shared validator runs.
shortcut common.Shortcut
args []string
// matching sub-op input; reach the same failing check.
subShortcut string
subInput string
// substring expected in both errors. We assert *contains* rather than
// equality because the batch path wraps the inner error with
// "operations[i] (<name>): " context — the inner message must match.
wantContains string
}{
{
name: "+cells-set missing sheet selector",
shortcut: CellsSet,
args: []string{"--range", "A1", "--cells", `[[{"value":"x"}]]`},
subShortcut: "+cells-set",
subInput: `{"range":"A1","cells":[[{"value":"x"}]]}`,
wantContains: "specify at least one of --sheet-id or --sheet-name",
},
{
name: "+cells-set both sheet-id and sheet-name",
shortcut: CellsSet,
args: []string{"--sheet-id", "sh1", "--sheet-name", "Sheet1", "--range", "A1", "--cells", `[[{"value":"x"}]]`},
subShortcut: "+cells-set",
subInput: `{"sheet-id":"sh1","sheet-name":"Sheet1","range":"A1","cells":[[{"value":"x"}]]}`,
wantContains: "mutually exclusive",
},
{
name: "+dim-insert missing sheet selector",
shortcut: DimInsert,
args: []string{"--position", "1", "--count", "1"},
subShortcut: "+dim-insert",
subInput: `{"position":"1","count":1}`,
wantContains: "specify at least one of --sheet-id or --sheet-name",
},
{
name: "+dim-insert count <= 0",
shortcut: DimInsert,
args: []string{"--sheet-id", "sh1", "--position", "5", "--count", "0"},
subShortcut: "+dim-insert",
subInput: `{"sheet-id":"sh1","position":"5","count":0}`,
wantContains: "--count must be > 0",
},
{
name: "+rows-resize --type pixel without --size",
shortcut: RowsResize,
args: []string{"--sheet-id", "sh1", "--range", "1:2", "--type", "pixel"},
subShortcut: "+rows-resize",
subInput: `{"sheet-id":"sh1","range":"1:2","type":"pixel"}`,
wantContains: "--type pixel requires --size",
},
{
name: "+sheet-delete missing sheet selector",
shortcut: SheetDelete,
args: []string{},
subShortcut: "+sheet-delete",
subInput: `{}`,
wantContains: "specify at least one of --sheet-id or --sheet-name",
},
{
name: "+float-image-create both image-token and image-uri",
shortcut: FloatImageCreate,
args: []string{"--sheet-id", "sh1", "--image-name", "x.png", "--image-token", "t", "--image-uri", "u", "--position-row", "0", "--position-col", "A", "--size-width", "100", "--size-height", "50"},
subShortcut: "+float-image-create",
subInput: `{"sheet-id":"sh1","image-name":"x.png","image-token":"t","image-uri":"u","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
wantContains: "mutually exclusive",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Standalone path: run the shortcut with --dry-run + bad args.
// Validate runs before DryRun, so we expect it to fail there.
_, _, standaloneErr := runShortcutCapturingErr(
t, tc.shortcut,
append([]string{"--url", testURL, "--dry-run"}, tc.args...),
)
if standaloneErr == nil {
t.Fatalf("standalone Validate accepted bad input — expected error containing %q", tc.wantContains)
}
if !strings.Contains(standaloneErr.Error(), tc.wantContains) {
t.Errorf("standalone error = %q, want substring %q", standaloneErr.Error(), tc.wantContains)
}
// Batch path: translate the matching sub-op. The translator wraps
// the inner error with "operations[i] (<shortcut>): " — assert the
// inner message survives the wrap.
var subInput map[string]interface{}
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
t.Fatalf("bad subInput JSON: %v", err)
}
rawOp := map[string]interface{}{
"shortcut": tc.subShortcut,
"input": subInput,
}
_, batchErr := translateBatchOp(rawOp, testToken, 0)
if batchErr == nil {
t.Fatalf("batch translator accepted bad input — expected error containing %q", tc.wantContains)
}
if !strings.Contains(batchErr.Error(), tc.wantContains) {
t.Errorf("batch error = %q, want substring %q (operations[i] prefix is fine)", batchErr.Error(), tc.wantContains)
}
// And the wrap context must include the sub-op index + shortcut
// name so error reports stay actionable in multi-op batches.
wrapHint := "operations[0] (" + tc.subShortcut + "):"
if !strings.Contains(batchErr.Error(), wrapHint) {
t.Errorf("batch error %q missing context prefix %q", batchErr.Error(), wrapHint)
}
})
}
}
// TestBatchOp_RejectsWrongScalarType locks the type-check that closes the
// silent-coercion gap: `operations` skips parse-time schema validation, and
// mapFlagView coerces a mismatched scalar to its zero value, so a sub-op field
// whose JSON type contradicts its flag-defs type must be rejected up front
// rather than landing as 0 / false in the wrong place.
func TestBatchOp_RejectsWrongScalarType(t *testing.T) {
t.Parallel()
cases := []struct {
name string
subShortcut string
subInput string
wantContains string
}{
{
name: "int flag given a string",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":2,"index":"abc"}`,
wantContains: "--index must be a number",
},
{
name: "int flag given a boolean",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":true,"index":0}`,
wantContains: "--source-index must be a number",
},
{
// Standalone cobra rejects 1.5 for an int flag at parse time;
// mapFlagView.Int would silently truncate it to 1, so the batch
// path must reject it too instead of executing on a floored index.
name: "int flag given a non-integer number",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":2,"index":1.5}`,
wantContains: "--index must be an integer",
},
{
name: "bool flag given a string",
subShortcut: "+cells-set",
subInput: `{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]],"allow-overwrite":"true"}`,
wantContains: "--allow-overwrite must be a boolean",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var subInput map[string]interface{}
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
t.Fatalf("bad subInput JSON: %v", err)
}
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translateBatchOp accepted wrong-typed field; want error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
})
}
}
// TestBatchOp_GuardsBeyondCobra locks the two batch sub-ops whose standalone
// required-flag enforcement lives OUTSIDE the shared *Input builder — so it is
// invisible to TestBatchOp_ErrorEquivalence and was missed by the refactor:
// - +csv-put: standalone requires one-of(start-cell, range) via cobra's
// MarkFlagsOneRequired (PostMount); a batch sub-op never runs cobra.
// - +sheet-move: standalone requires --index (>=0) and source-index>=0 in
// SheetMove.Validate; the batch path uses a dedicated builder.
//
// Without an explicit guard, mapFlagView's flag-default fallback silently wins
// (start-cell→"A1", index→0), so the batch sub-op diverges from the standalone
// contract instead of failing.
func TestBatchOp_GuardsBeyondCobra(t *testing.T) {
t.Parallel()
cases := []struct {
name string
subShortcut string
subInput string
wantContains string
}{
{
name: "+csv-put without start-cell or range",
subShortcut: "+csv-put",
subInput: `{"sheet-id":"sh1","csv":"a,b"}`,
wantContains: "--start-cell or --range is required",
},
{
name: "+sheet-move without index",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":2}`,
wantContains: "requires index",
},
{
name: "+sheet-move negative index",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":2,"index":-1}`,
wantContains: "--index must be >= 0",
},
{
name: "+sheet-move negative source-index",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":-1,"index":0}`,
wantContains: "--source-index must be >= 0",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var subInput map[string]interface{}
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
t.Fatalf("bad subInput JSON: %v", err)
}
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translateBatchOp accepted bad input; want error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
})
}
}
// TestBatchOp_RejectsBadSubOpInput pins down the secondary guard: for
// inputs that cobra's MarkFlagRequired catches on the standalone path,
// the +batch-update sub-op (which has no cobra layer) must still reject
// CLI-side with its own friendly error before issuing any API call. This
// closes the original bug — a sub-op missing --sheet-id used to slip
// through and surface as "sheet undefined not found" only after a
// network round-trip.
func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
t.Parallel()
cases := []struct {
name string
subShortcut string
subInput string
wantContains string
}{
{
"+cells-set missing --range",
"+cells-set",
`{"sheet-id":"sh1","cells":[[{"value":"x"}]]}`,
"--range is required",
},
{
"+dim-insert missing --position",
"+dim-insert",
`{"sheet-id":"sh1","count":1}`,
"--position is required",
},
{
"+rows-resize missing --type",
"+rows-resize",
`{"sheet-id":"sh1","range":"1:1"}`,
"--type is required",
},
{
"+range-copy missing --target-range",
"+range-copy",
`{"sheet-id":"sh1","source-range":"A1:B2"}`,
"--target-range is required",
},
{
"+sheet-rename missing --title",
"+sheet-rename",
`{"sheet-id":"sh1"}`,
"--title is required",
},
{
"+chart-update missing --chart-id",
"+chart-update",
`{"sheet-id":"sh1","properties":{"title":"T"}}`,
"--chart-id is required",
},
{
"+filter-create missing --range",
"+filter-create",
`{"sheet-id":"sh1"}`,
"--range is required",
},
{
"+float-image-update missing --float-image-id",
"+float-image-update",
`{"sheet-id":"sh1","image-name":"x.png","image-token":"t","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
"--float-image-id is required",
},
// +float-image-update's core (image_name / position / size) is mandatory
// on update too — the tool rejects without them and +float-image-list
// can't backfill image_name. cobra gates these on the standalone path;
// the batch sub-op must reject them here. The image source stays optional
// (omitting it keeps the current image), so these inputs omit it.
{
"+float-image-update missing --image-name",
"+float-image-update",
`{"sheet-id":"sh1","float-image-id":"fi1","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
"--image-name is required",
},
{
"+float-image-update missing position",
"+float-image-update",
`{"sheet-id":"sh1","float-image-id":"fi1","image-name":"x.png","size-width":100,"size-height":50}`,
"--position-row and --position-col are required",
},
{
"+float-image-update missing size",
"+float-image-update",
`{"sheet-id":"sh1","float-image-id":"fi1","image-name":"x.png","position-row":0,"position-col":"A"}`,
"--size-width and --size-height are required",
},
// +filter-{update,delete} need sheet-id (not sheet-name) because
// server contract: filter_id === sheet_id, and we can't resolve
// sheet-name → sheet-id mid-batch.
{
"+filter-update with --sheet-name only (filter_id must equal sheet_id)",
"+filter-update",
`{"sheet-name":"Sheet1","range":"A1:F1000","properties":{"rules":[]}}`,
"+filter-update requires --sheet-id",
},
{
"+filter-delete with --sheet-name only (filter_id must equal sheet_id)",
"+filter-delete",
`{"sheet-name":"Sheet1"}`,
"+filter-delete requires --sheet-id",
},
// +sparkline-update requires sparkline_id on every
// properties.sparklines[i] (server contract). CLI surfaces this
// with a pointer to +sparkline-list so the agent doesn't have to
// guess the id from an opaque server-side rejection.
{
"+sparkline-update item missing sparkline_id",
"+sparkline-update",
`{"sheet-id":"sh1","group-id":"g1","properties":{"sparklines":[{"position":{"row":0,"col":"A"}}]}}`,
"missing sparkline_id",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var subInput map[string]interface{}
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
t.Fatalf("bad subInput JSON: %v", err)
}
rawOp := map[string]interface{}{
"shortcut": tc.subShortcut,
"input": subInput,
}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translator accepted bad input — expected error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
})
}
}
// TestBatchOp_SchemaValidatesSubOps confirms the schema-driven
// validator fires on +batch-update sub-operations the same way it
// fires on standalone shortcuts. mapFlagView.Command() returns the
// sub-op's shortcut name, so validateInputAgainstSchema (called at
// each input builder's tail) routes through the same (command, flag)
// lookup pipeline a standalone invocation would. This regression
// pins that wiring — without it, agents could slip past CLI-side
// schema checks by wrapping a bad input in +batch-update.
func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
t.Parallel()
cases := []struct {
name string
subShortcut string
subInput string
wantContains string
}{
// +pivot-create properties.values items enforce summarize_by
// enum — schema rejects an out-of-enum value as a sub-op too.
{
"+pivot-create summarize_by out of enum",
"+pivot-create",
`{"sheet-id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
"summarize_by",
},
// +chart-create properties.position.row has minimum:0 — P0
// addition; validator must catch -1 even in the batch path.
{
"+chart-create position.row below minimum",
"+chart-create",
`{"sheet-id":"sh1","properties":{"position":{"row":-1,"col":"A"},"size":{"width":400,"height":300}}}`,
"below minimum",
},
// +cells-set --cells is a 2D array of objects per the
// upstream-fixed schema; sub-op passing an object must be
// rejected at the schema layer (not "expected JSON array").
{
"+cells-set cells wrong shape",
"+cells-set",
`{"sheet-id":"sh1","range":"A1","cells":{"foo":"bar"}}`,
`expected type "array"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var subInput map[string]interface{}
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
t.Fatalf("bad subInput JSON: %v", err)
}
rawOp := map[string]interface{}{
"shortcut": tc.subShortcut,
"input": subInput,
}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translator accepted schema-violating sub-op — expected error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
})
}
}
// TestBatchOp_DispatchCoversReportedBugs is a focused guard for the two
// originally reported failures: +range-copy and +rows-resize sub-ops must
// translate to the correct MCP body (not a near-passthrough that drops
// required fields).
func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) {
t.Parallel()
// +range-copy → transform_range with range / destination_range (not the
// raw source_range / target_range that used to leak through).
body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[{"shortcut":"+range-copy","input":{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"all"}}]`,
"--yes",
})
ops := decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
copyIn := ops[0].(map[string]interface{})["input"].(map[string]interface{})
if copyIn["range"] != "A1:B2" || copyIn["destination_range"] != "A10" {
t.Errorf("+range-copy sub-op body wrong: %#v", copyIn)
}
if copyIn["operation"] != "copy" {
t.Errorf("+range-copy operation = %v, want copy", copyIn["operation"])
}
// +rows-resize → resize_range with range + resize_height. The CLI's single
// "23" input must be expanded to "23:23" because resize_range rejects
// bare single-element ranges.
body = parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","type":"pixel","size":40}}]`,
"--yes",
})
ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
resizeIn := ops[0].(map[string]interface{})["input"].(map[string]interface{})
if resizeIn["range"] != "23:23" {
t.Errorf("+rows-resize single-row range = %v, want 23:23", resizeIn["range"])
}
rh, _ := resizeIn["resize_height"].(map[string]interface{})
if rh == nil || rh["type"] != "pixel" {
t.Errorf("+rows-resize resize_height wrong: %#v", resizeIn)
}
}
// TestBatchOp_RequiredFlagParity is the systematic standalone-vs-batch parity
// contract: for EVERY batchable shortcut, a +batch-update sub-op that satisfies
// the sheet locator but omits all of the shortcut's business-required flags must
// fail in translateBatchOp — never silently fall back to a default. The earlier
// cases (TestBatchOp_ErrorEquivalence / GuardsBeyondCobra) cover hand-picked
// shortcuts; this one is data-driven over batchOpDispatch + flag-defs, so it
// guards the whole surface and auto-covers any shortcut added later. If a future
// refactor moves a required check out of the shared *Input builder (the exact
// failure mode behind the csv-put / sheet-move gaps), the corresponding sub-op
// would start accepting missing args and this test fails.
func TestBatchOp_RequiredFlagParity(t *testing.T) {
t.Parallel()
defs, err := loadFlagDefs()
if err != nil {
t.Fatalf("loadFlagDefs: %v", err)
}
// Flags supplied by the +batch-update top level (url/token), or that form the
// sub-op's own sheet selector, are context — not "business" inputs.
locator := map[string]bool{
"url": true, "spreadsheet-token": true,
"sheet-id": true, "sheet-name": true,
"target-sheet-id": true, "target-sheet-name": true,
}
// How each command expresses its sheet locator in a sub-op, so the error we
// trigger is the business one, not a missing-locator error.
sheetSel := func(cmd string) map[string]interface{} {
switch cmd {
case "+sheet-create": // create needs no existing-sheet anchor
return map[string]interface{}{}
case "+pivot-create": // placement selector is target-sheet-*; data source is --source
return map[string]interface{}{"target-sheet-id": "sh1"}
default:
return map[string]interface{}{"sheet-id": "sh1"}
}
}
for cmd := range batchOpDispatch {
spec, ok := defs[cmd]
if !ok {
t.Errorf("%s is in batchOpDispatch but has no flag-defs entry", cmd)
continue
}
var business []string
for _, fl := range spec.Flags {
if fl.Kind == "system" || locator[fl.Name] {
continue
}
if fl.Required == "required" || fl.Required == "xor" {
business = append(business, fl.Name)
}
}
if len(business) == 0 {
continue // only-locator commands (sheet-delete/hide/unhide/copy/filter-delete): nothing to omit
}
t.Run(cmd, func(t *testing.T) {
t.Parallel()
rawOp := map[string]interface{}{"shortcut": cmd, "input": sheetSel(cmd)}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Errorf("%s: a sub-op omitting business-required %v was accepted; want an error "+
"(batch must reject missing required flags, not silently default)", cmd, business)
return
}
// The sub-op DID supply a sheet selector, so a missing-locator error
// would mean the fixture is wrong and the business-required check never
// actually ran — reject that shape so the parity check stays honest.
if strings.Contains(err.Error(), "specify at least one of") {
t.Errorf("%s: got a missing-locator error, not a business-required one (fixture bug): %v", cmd, err)
}
})
}
}

View File

@@ -1,348 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── +batch-update sub-op dispatch ─────────────────────────────────────
//
// 用户传给 +batch-update --operations 的形态是 CLI 视角的 {shortcut, input}
//
// [{"shortcut": "+range-copy", "input": {"sheet_id":"...","source-range":"A1:B2","target-range":"A10"}}, ...]
//
// input 里用的是该 shortcut 的 **CLI flag 名**(与 standalone 调用一致;连字符 /
// 下划线两种写法都接受)。底层 MCP batch_update tool 要的是
// {tool_name, input(MCP body)} —— body 的字段名往往与 CLI flag 名不同
// (如 +range-copy 的 source-range/target-range 要翻成 range/destination_range
//
// 关键:每个子操作复用 **standalone shortcut 同一套 flag→body translator**
// (那些 *Input 构建函数,现在统一接收 flagView 接口)。这样 batch 子操作
// 产出的 MCP body 与该 shortcut 单独调用产出的 body 完全一致(由
// batch-vs-standalone 契约测试保证。dispatch 表只列**可纳入 atomic batch
// 的 write shortcut**——读操作、fan-out wrapper+batch-update 自身、
// +cells-batch-set-style、+cells-batch-clear、+dropdown-{update,delete})一律不放进表里,
// 用户传到 +batch-update 里会被 translator 拒绝。
// batchTranslateFn turns a sub-op's CLI-shape input (via flagView) into the MCP
// tool body for the underlying batch_update sub-tool. token is the
// +batch-update top-level spreadsheet token; sheetID/sheetName are the resolved
// sheet selector for this sub-op. The returned body already carries excel_id
// and (where the tool needs one) the operation discriminator — exactly as the
// standalone shortcut would emit.
type batchTranslateFn func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error)
type batchOpMapping struct {
// mcpToolName 是底层 MCP batch_update 接受的 tool_name。
mcpToolName string
// translate 复用 standalone 的 *Input 构建逻辑,产出 MCP body。
translate batchTranslateFn
}
// sheetSelectorFlagsForSubOp returns the (id, name) flag names a +batch-update
// sub-op uses to express its placement / context sheet. Defaults are
// `sheet-id` / `sheet-name`; +pivot-create deviates because its create
// shortcut renamed the placement selector to `target-sheet-id` /
// `target-sheet-name` (the data-source sheet is encoded in --source as
// `'SheetName'!Range`, not in a sheet selector flag). Update / delete on
// pivot still use the default names — only the create create-side
// shortcut was renamed.
func sheetSelectorFlagsForSubOp(shortcut string) (string, string) {
if shortcut == "+pivot-create" {
return "target-sheet-id", "target-sheet-name"
}
return "sheet-id", "sheet-name"
}
// objCreateTranslate / objUpdateTranslate / objDeleteTranslate bind an object
// CRUD spec to the shared object_crud builders.
func objCreateTranslate(spec objectCRUDSpec) batchTranslateFn {
return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
return objectCreateInput(fv, token, sheetID, sheetName, spec)
}
}
func objUpdateTranslate(spec objectCRUDSpec) batchTranslateFn {
return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
return objectUpdateInput(fv, token, sheetID, sheetName, spec)
}
}
func objDeleteTranslate(spec objectCRUDSpec) batchTranslateFn {
return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
return objectDeleteInput(fv, token, sheetID, sheetName, spec)
}
}
// batchOpDispatch covers every write shortcut that can join an atomic batch.
// Each entry plugs the shortcut's standalone xxxInput builder into the
// batch translator path — so the body is byte-identical to the standalone
// invocation (locked by TestBatchOp_BodyMatchesStandalone) and the missing-
// flag error is identical too (locked by TestBatchOp_ErrorEquivalence).
var batchOpDispatch = map[string]batchOpMapping{
// ─── 单元格内容 ──────────────────────────────────────────────────
"+cells-set": {"set_cell_range", cellsSetInput},
"+cells-set-style": {"set_cell_range", cellsSetStyleInput},
"+cells-clear": {"clear_cell_range", cellsClearInput},
"+cells-replace": {"replace_data", replaceInput},
"+csv-put": {"set_range_from_csv", csvPutInput},
"+dropdown-set": {"set_cell_range", dropdownSetInput},
// ─── 单元格合并 (merge_cells, operation 区分) ────────────────────
"+cells-merge": {"merge_cells", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return mergeInput(fv, token, sid, sname, "merge", true)
}},
"+cells-unmerge": {"merge_cells", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return mergeInput(fv, token, sid, sname, "unmerge", false)
}},
// ─── 行列结构 (modify_sheet_structure, operation 区分) ──────────
"+dim-insert": {"modify_sheet_structure", dimInsertInput},
"+dim-delete": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return dimRangeOpInput(fv, token, sid, sname, "delete")
}},
"+dim-hide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return dimRangeOpInput(fv, token, sid, sname, "hide")
}},
"+dim-unhide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return dimRangeOpInput(fv, token, sid, sname, "unhide")
}},
"+dim-freeze": {"modify_sheet_structure", dimFreezeInput},
"+dim-group": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return dimGroupInput(fv, token, sid, sname, "group")
}},
"+dim-ungroup": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return dimGroupInput(fv, token, sid, sname, "ungroup")
}},
// ─── 行高列宽 (resize_range, 无 operation 字段) ─────────────────
"+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return resizeInput(fv, token, sid, sname, "row")
}},
"+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return resizeInput(fv, token, sid, sname, "column")
}},
// ─── 区域操作 (transform_range, operation 区分) ─────────────────
"+range-move": {"transform_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return transformMoveCopyInput(fv, token, sid, sname, "move", false)
}},
"+range-copy": {"transform_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return transformMoveCopyInput(fv, token, sid, sname, "copy", true)
}},
"+range-fill": {"transform_range", rangeFillInput},
"+range-sort": {"transform_range", rangeSortInput},
// ─── 工作簿 / 子表 (modify_workbook_structure, operation 区分) ──
"+sheet-create": {"modify_workbook_structure", func(fv flagView, token, _, _ string) (map[string]interface{}, error) {
return sheetCreateInput(fv, token)
}},
"+sheet-delete": {"modify_workbook_structure", sheetDeleteInput},
"+sheet-rename": {"modify_workbook_structure", sheetRenameInput},
"+sheet-move": {"modify_workbook_structure", sheetMoveBatchInput},
"+sheet-copy": {"modify_workbook_structure", sheetCopyInput},
"+sheet-hide": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
return sheetVisibilityInput(fv, t, sid, sn, "hide")
}},
"+sheet-unhide": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
return sheetVisibilityInput(fv, t, sid, sn, "unhide")
}},
"+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput},
"+sheet-show-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
return sheetVisibilityInput(fv, t, sid, sn, "show_gridline")
}},
"+sheet-hide-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
return sheetVisibilityInput(fv, t, sid, sn, "hide_gridline")
}},
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
"+chart-update": {"manage_chart_object", objUpdateTranslate(chartSpec)},
"+chart-delete": {"manage_chart_object", objDeleteTranslate(chartSpec)},
"+pivot-create": {"manage_pivot_table_object", objCreateTranslate(pivotSpec)},
"+pivot-update": {"manage_pivot_table_object", objUpdateTranslate(pivotSpec)},
"+pivot-delete": {"manage_pivot_table_object", objDeleteTranslate(pivotSpec)},
"+cond-format-create": {"manage_conditional_format_object", objCreateTranslate(condFormatSpec)},
"+cond-format-update": {"manage_conditional_format_object", objUpdateTranslate(condFormatSpec)},
"+cond-format-delete": {"manage_conditional_format_object", objDeleteTranslate(condFormatSpec)},
"+filter-create": {"manage_filter_object", filterCreateInput},
"+filter-update": {"manage_filter_object", filterUpdateInput},
"+filter-delete": {"manage_filter_object", filterDeleteInput},
"+filter-view-create": {"manage_filter_view_object", objCreateTranslate(filterViewSpec)},
"+filter-view-update": {"manage_filter_view_object", objUpdateTranslate(filterViewSpec)},
"+filter-view-delete": {"manage_filter_view_object", objDeleteTranslate(filterViewSpec)},
"+sparkline-create": {"manage_sparkline_object", objCreateTranslate(sparklineSpec)},
"+sparkline-update": {"manage_sparkline_object", objUpdateTranslate(sparklineSpec)},
"+sparkline-delete": {"manage_sparkline_object", objDeleteTranslate(sparklineSpec)},
"+float-image-create": {"manage_float_image_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
if err := rejectLocalImageInBatch(fv); err != nil {
return nil, err
}
return floatImageWriteInput(fv, token, sid, sname, "create", false, "")
}},
"+float-image-update": {"manage_float_image_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
if err := rejectLocalImageInBatch(fv); err != nil {
return nil, err
}
return floatImageWriteInput(fv, token, sid, sname, "update", true, "")
}},
"+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)},
}
// rejectLocalImageInBatch blocks the local-file --image source inside
// +batch-update: a batch sub-op has no upload phase, so the file could not be
// turned into a file_token. Callers must pass --image-token / --image-uri.
func rejectLocalImageInBatch(fv flagView) error {
if strings.TrimSpace(fv.Str("image")) != "" {
return common.FlagErrorf("--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead")
}
return nil
}
// sheetMoveBatchInput translates +sheet-move inside a batch. Unlike the
// standalone shortcut it cannot issue the get_workbook_structure read that
// auto-derives sheet_id / source_index, so both must be supplied explicitly.
func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if sheetID == "" {
return nil, common.FlagErrorf("+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)")
}
if !fv.Changed("source-index") {
return nil, common.FlagErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)")
}
if fv.Int("source-index") < 0 {
return nil, common.FlagErrorf("--source-index must be >= 0")
}
// Standalone +sheet-move requires --index (see SheetMove.Validate). A batch
// sub-op skips that path, and mapFlagView falls back to the flag default (0),
// which would silently move the sheet to the front. Require it explicitly so
// the batch contract matches the standalone one.
if !fv.Changed("index") {
return nil, common.FlagErrorf("+sheet-move in +batch-update requires index")
}
if fv.Int("index") < 0 {
return nil, common.FlagErrorf("--index must be >= 0")
}
return map[string]interface{}{
"excel_id": token,
"operation": "move",
"sheet_id": sheetID,
"source_index": fv.Int("source-index"),
"target_index": fv.Int("index"),
}, nil
}
// reservedSubOpKeys 是禁止用户在 sub-op input 里手填的 key —— 它们由
// +batch-update 顶层 --url/--token 统一提供excel_id / spreadsheet_token / url
var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
// translateBatchOp 把一个 CLI 视角的 {shortcut, input} 翻成底层 MCP
// batch_update 的 {tool_name, input}。`index` 用于错误信息定位。input 用
// shortcut 的 CLI flag 名(连字符/下划线均可),经该 shortcut 的 standalone
// translator 翻成 MCP body。
//
// 失败场景:
// - shortcut 字段缺失 / 非 string
// - shortcut 不在 dispatch 表拼写错read 操作;嵌套 fan-out wrapper
// - input 不是 object
// - input 里手填了 operation由 shortcut 名隐含,禁手填以防 mismatch
// - input 里手填了 excel_id / spreadsheet_token / url
// - 子操作的 translator 报错(如缺必填字段)
func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("operations[%d] must be a JSON object", index)
}
scRaw, present := op["shortcut"]
if !present {
return nil, common.FlagErrorf("operations[%d]: 'shortcut' field is required", index)
}
sc, ok := scRaw.(string)
if !ok || sc == "" {
return nil, common.FlagErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw)
}
mapping, ok := batchOpDispatch[sc]
if !ok {
return nil, common.FlagErrorf(
"operations[%d]: shortcut %q not allowed in +batch-update "+
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded; "+
"run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)",
index, sc,
)
}
inputRaw, hasInput := op["input"]
var input map[string]interface{}
if !hasInput || inputRaw == nil {
input = map[string]interface{}{}
} else {
input, ok = inputRaw.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("operations[%d] (%s): 'input' must be a JSON object (got %T)", index, sc, inputRaw)
}
}
// 禁手填 operation —— 由 shortcut 名表达,手填易与 shortcut 不一致。
if _, has := input["operation"]; has {
return nil, common.FlagErrorf(
"operations[%d] (%s): do not pass input.operation manually — it is implied by the shortcut name",
index, sc,
)
}
// 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。
for _, k := range reservedSubOpKeys {
if _, has := input[k]; has {
return nil, common.FlagErrorf(
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
index, sc, k,
)
}
}
// 拒绝任何额外的 sub-op 顶层 key防御未来 schema drift / 用户笔误)。
for k := range op {
if k != "shortcut" && k != "input" {
return nil, common.FlagErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
}
}
fv := newMapFlagViewForCommand(sc, input)
// operations is skipped by parse-time schema validation, so type-check the
// sub-op's scalar fields here before the translator reads them via
// Int/Bool/Float64 (which would otherwise coerce a wrong type to zero).
if err := fv.validateRawTypes(); err != nil {
return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err)
}
sheetIDFlag, sheetNameFlag := sheetSelectorFlagsForSubOp(sc)
sheetID := strings.TrimSpace(fv.Str(sheetIDFlag))
sheetName := strings.TrimSpace(fv.Str(sheetNameFlag))
body, err := mapping.translate(fv, token, sheetID, sheetName)
if err != nil {
return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err)
}
return map[string]interface{}{
"tool_name": mapping.mcpToolName,
"input": body,
}, nil
}
// translateBatchOperations 翻译整个 ops 数组fail-fast遇错立即返回。
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
if len(rawOps) == 0 {
return nil, common.FlagErrorf("--operations must be a non-empty JSON array")
}
out := make([]interface{}, 0, len(rawOps))
for i, raw := range rawOps {
translated, err := translateBatchOp(raw, token, i)
if err != nil {
return nil, err
}
out = append(out, translated)
}
return out, nil
}

View File

@@ -1,83 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// +csv-put locates with --start-cell, while +csv-get / +cells-set locate with
// --range. Agents routinely carry --range over to +csv-put and hit a guaranteed
// first-try failure. csvPutInput now accepts --range as an alias for
// --start-cell; a range value collapses to its top-left cell.
func TestCsvPutInput_RangeAliasForStartCell(t *testing.T) {
tests := []struct {
name string
raw map[string]interface{}
wantAnchor string
}{
{"start-cell direct (unchanged)", map[string]interface{}{"csv": "a,b", "start-cell": "B2"}, "B2"},
{"range alias, single cell", map[string]interface{}{"csv": "a,b", "range": "B2"}, "B2"},
{"range alias collapses to top-left", map[string]interface{}{"csv": "a,b", "range": "A1:H17"}, "A1"},
{"start-cell wins when both set", map[string]interface{}{"csv": "a,b", "start-cell": "C3", "range": "A1:H17"}, "C3"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fv := newMapFlagViewForCommand("+csv-put", tt.raw)
input, err := csvPutInput(fv, "tok", "sid", "")
if err != nil {
t.Fatalf("csvPutInput returned error: %v", err)
}
got, _ := input["start_cell"].(string)
if got != tt.wantAnchor {
t.Errorf("start_cell = %q, want %q", got, tt.wantAnchor)
}
})
}
}
// With neither --start-cell nor --range explicitly set, csvPutInput rejects the
// call instead of silently anchoring at the "A1" flag default. Standalone never
// reaches this path — cobra's MarkFlagsOneRequired(start-cell, range) catches it
// first — but a +batch-update sub-op skips cobra, so the guard must live in the
// shared builder too. Otherwise a batch +csv-put with no anchor silently pastes
// at A1, diverging from the standalone contract.
func TestCsvPutInput_RequiresStartCellOrRange(t *testing.T) {
fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{"csv": "a,b"})
_, err := csvPutInput(fv, "tok", "sid", "")
if err == nil {
t.Fatal("csvPutInput accepted missing start-cell/range; want a required-flag error")
}
if !strings.Contains(err.Error(), "--start-cell or --range is required") {
t.Errorf("error = %q, want it to mention '--start-cell or --range is required'", err.Error())
}
}
// csvPutWriteRangeFromInput surfaces the real paste footprint so agents can see
// how far a CSV reaches from its anchor — it auto-expands to the CSV's own size,
// not to any user-set range.
func TestCsvPutWriteRangeFromInput(t *testing.T) {
tests := []struct {
name string
input map[string]interface{}
want string
ok bool
}{
{"3x3 at B2", map[string]interface{}{"start_cell": "B2", "csv": "a,b,c\n1,2,3\n4,5,6"}, "B2:D4", true},
{"single cell at A1", map[string]interface{}{"start_cell": "A1", "csv": "x"}, "A1:A1", true},
{"1 row 3 cols at C3", map[string]interface{}{"start_cell": "C3", "csv": "a,b,c"}, "C3:E3", true},
{"ragged rows use max width", map[string]interface{}{"start_cell": "A1", "csv": "a,b\nc,d,e"}, "A1:C2", true},
{"missing csv", map[string]interface{}{"start_cell": "A1"}, "", false},
{"non-single anchor", map[string]interface{}{"start_cell": "A1:B2", "csv": "x"}, "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := csvPutWriteRangeFromInput(tt.input)
if ok != tt.ok || got != tt.want {
t.Errorf("got (%q, %v), want (%q, %v)", got, ok, tt.want, tt.ok)
}
})
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,578 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and
// verifies the shortcut decodes the JSON-string output, surfaces it as
// envelope data, and finishes without error.
func TestExecute_WorkbookInfo_Happy(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"Sheet1","row_count":1000,"column_count":26,"index":0}]}`)
out, err := runShortcutWithStubs(t, WorkbookInfo, []string{"--url", testURL}, stub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 1 {
t.Fatalf("sheets len = %d, want 1", len(sheets))
}
sheet, _ := sheets[0].(map[string]interface{})
if sheet["sheet_id"] != "sh1" || sheet["title"] != "Sheet1" {
t.Errorf("unexpected sheet: %#v", sheet)
}
}
// TestExecute_WorkbookInfo_ToolError surfaces a non-zero code in the
// envelope shape and asserts CLI returns an error envelope.
func TestExecute_WorkbookInfo_ToolError(t *testing.T) {
t.Parallel()
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read",
Body: map[string]interface{}{
"code": 1310201,
"msg": "spreadsheet not found",
"data": map[string]interface{}{},
},
}
stdout, stderr, err := func() (string, string, error) {
parent, stdout, stderr, reg := newTestRig(t, WorkbookInfo)
reg.Register(stub)
parent.SetArgs([]string{"+workbook-info", "--url", testURL})
err := parent.Execute()
return stdout.String(), stderr.String(), err
}()
if err == nil {
t.Fatalf("expected non-zero code to surface as error; stdout=%s stderr=%s", stdout, stderr)
}
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, "1310201") && !strings.Contains(combined, "not found") {
t.Errorf("expected error code in envelope; got=%s|%s|%v", stdout, stderr, err)
}
}
// TestExecute_SheetMove_LookupsIndex covers the two-step path: SheetMove
// when only --sheet-name is given (and --source-index omitted) first
// reads the workbook structure to derive sheet_id + source_index, then
// posts the modify_workbook_structure call.
func TestExecute_SheetMove_LookupsIndex(t *testing.T) {
t.Parallel()
lookup := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","sheet_name":"汇总","index":3}]}`)
move := toolOutputStub(testToken, "write", `{"sheet_id":"sh1"}`)
out, err := runShortcutWithStubs(t, SheetMove,
[]string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"},
lookup, move,
)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
// Inspect the captured move body: source_index should be 3 (looked up),
// not <resolve>, and sheet_id should be the resolved id.
if move.CapturedBody == nil {
t.Fatal("move stub didn't capture a body")
}
body := decodeRawEnvelopeBody(t, move.CapturedBody)
input := decodeToolInput(t, body, "modify_workbook_structure")
if input["sheet_id"] != "sh1" {
t.Errorf("sheet_id = %v, want sh1 (resolved from --sheet-name)", input["sheet_id"])
}
if input["source_index"].(float64) != 3 {
t.Errorf("source_index = %v, want 3 (from lookup)", input["source_index"])
}
if input["target_index"].(float64) != 0 {
t.Errorf("target_index = %v, want 0", input["target_index"])
}
}
// TestExecute_SheetMove_LookupsIndexByTitle covers the same lookup path as
// above but with get_workbook_structure exposing the display name as "title"
// (the field the real tool returns) instead of "sheet_name". lookupSheetIndex
// must resolve --sheet-name against either key.
func TestExecute_SheetMove_LookupsIndexByTitle(t *testing.T) {
t.Parallel()
lookup := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"汇总","index":3}]}`)
move := toolOutputStub(testToken, "write", `{"sheet_id":"sh1"}`)
out, err := runShortcutWithStubs(t, SheetMove,
[]string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"},
lookup, move,
)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
if move.CapturedBody == nil {
t.Fatal("move stub didn't capture a body")
}
body := decodeRawEnvelopeBody(t, move.CapturedBody)
input := decodeToolInput(t, body, "modify_workbook_structure")
if input["sheet_id"] != "sh1" {
t.Errorf("sheet_id = %v, want sh1 (resolved from --sheet-name via title)", input["sheet_id"])
}
if input["source_index"].(float64) != 3 {
t.Errorf("source_index = %v, want 3 (from lookup)", input["source_index"])
}
}
// TestExecute_CellsGet covers a multi-range read end-to-end.
func TestExecute_CellsGet(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "read", `{"ranges":[{"range":"A1:B2","cells":[[{"value":1}]]}]}`)
out, err := runShortcutWithStubs(t, CellsGet,
[]string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2"}, stub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
if data := decodeEnvelopeData(t, out); data["ranges"] == nil {
t.Fatalf("expected ranges in output; got=%#v", data)
}
}
// TestExecute_CellsSet covers the write path including allow-overwrite
// override.
func TestExecute_CellsSet(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"updated_cells":2}`)
out, err := runShortcutWithStubs(t, CellsSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:B1",
"--cells", `[[{"value":"x"},{"value":"y"}]]`,
}, stub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "set_cell_range")
if input["range"] != "A1:B1" {
t.Errorf("wire range = %v", input["range"])
}
if data := decodeEnvelopeData(t, out); data["updated_cells"].(float64) != 2 {
t.Errorf("updated_cells = %v", data["updated_cells"])
}
}
// TestExecute_DropdownSet covers the fan-out → set_cell_range write.
func TestExecute_DropdownSet(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{}`)
_, err := runShortcutWithStubs(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A2:A4",
"--options", `["x","y"]`,
"--multiple",
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
if len(cells) != 3 {
t.Errorf("wire cells rows = %d, want 3", len(cells))
}
}
// TestExecute_DropdownUpdate_Batch covers the batch_update fan-out for
// dropdown-update. Verifies the captured request has 2 ops.
func TestExecute_DropdownUpdate_Batch(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true},{"ok":true}]}`)
_, err := runShortcutWithStubs(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", `["sheet1!A2:A5","sheet1!C2:C5"]`,
"--options", `["a","b"]`,
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
if len(ops) != 2 {
t.Errorf("operations len = %d, want 2", len(ops))
}
}
// TestExecute_CellsSearch covers the search read path with options.
func TestExecute_CellsSearch(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "read", `{"matches":[{"cell":"B2"}],"has_more":false}`)
out, err := runShortcutWithStubs(t, CellsSearch, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--find", "foo", "--match-case",
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
data := decodeEnvelopeData(t, out)
if data["matches"] == nil {
t.Errorf("matches missing: %#v", data)
}
}
// TestExecute_RangeMove covers the transform_range write path.
func TestExecute_RangeMove(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"moved":true}`)
out, err := runShortcutWithStubs(t, RangeMove, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--source-range", "A1:C5",
"--target-range", "D1",
}, stub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "transform_range")
if input["operation"] != "move" {
t.Errorf("operation = %v, want move", input["operation"])
}
}
// TestExecute_FilterCreate covers the filter special case (range mandatory,
// optional --data conditions merge).
func TestExecute_FilterCreate(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"filter_id":"sh1"}`)
out, err := runShortcutWithStubs(t, FilterCreate, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:F100",
"--properties", `{"rules":[{"column_index":"B","conditions":[{"type":"multiValue","compare_type":"equal","values":["x"]}]}]}`,
}, stub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "manage_filter_object")
props, _ := input["properties"].(map[string]interface{})
if props["range"] != "A1:F100" {
t.Errorf("properties.range = %v", props["range"])
}
if props["rules"] == nil {
t.Errorf("rules missing: %#v", props)
}
}
// TestExecute_BatchUpdate_Translated covers the CLI-shape → MCP-shape
// translation: user passes {shortcut, input}, batchOpDispatch maps it to
// {tool_name, input(+operation, +excel_id)} before the tool call. Also
// verifies --continue-on-error.
func TestExecute_BatchUpdate_Translated(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`)
_, err := runShortcutWithStubs(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[{"shortcut":"+cells-set","input":{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]]}}]`,
"--continue-on-error",
"--yes",
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "batch_update")
if input["continue_on_error"] != true {
t.Errorf("continue_on_error not propagated: %#v", input)
}
ops, _ := input["operations"].([]interface{})
if len(ops) != 1 {
t.Fatalf("operations length = %d, want 1", len(ops))
}
op := ops[0].(map[string]interface{})
if op["tool_name"] != "set_cell_range" {
t.Errorf("op.tool_name = %v, want set_cell_range (translated from +cells-set)", op["tool_name"])
}
subInput, _ := op["input"].(map[string]interface{})
if subInput["excel_id"] != testToken {
t.Errorf("op.input.excel_id = %v, want %s (translator should inject)", subInput["excel_id"], testToken)
}
if _, has := subInput["operation"]; has {
t.Errorf("op.input.operation present but +cells-set should not inject one: %#v", subInput)
}
}
// TestExecute_BatchUpdate_ContinueOnErrorPrecedence locks the flag-vs-envelope
// precedence: an explicit --continue-on-error=false must keep the strict
// transaction even when the --operations envelope carries continue_on_error:true,
// while an envelope value still applies when the flag is absent. Guards against
// the regression where the flag was read by value (runtime.Bool) rather than by
// Changed().
func TestExecute_BatchUpdate_ContinueOnErrorPrecedence(t *testing.T) {
t.Parallel()
envelope := `{"operations":[{"shortcut":"+cells-set","input":{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]]}}],"continue_on_error":true}`
t.Run("explicit false overrides envelope", func(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`)
_, err := runShortcutWithStubs(t, BatchUpdate, []string{
"--url", testURL,
"--operations", envelope,
"--continue-on-error=false",
"--yes",
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
input := decodeToolInput(t, decodeRawEnvelopeBody(t, stub.CapturedBody), "batch_update")
if input["continue_on_error"] == true {
t.Errorf("explicit --continue-on-error=false must win over envelope; got continue_on_error=%#v", input["continue_on_error"])
}
})
t.Run("envelope applies when flag absent", func(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`)
_, err := runShortcutWithStubs(t, BatchUpdate, []string{
"--url", testURL,
"--operations", envelope,
"--yes",
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
input := decodeToolInput(t, decodeRawEnvelopeBody(t, stub.CapturedBody), "batch_update")
if input["continue_on_error"] != true {
t.Errorf("envelope continue_on_error:true should apply when --continue-on-error absent; got %#v", input["continue_on_error"])
}
})
}
// TestExecute_WorkbookCreate covers the create POST + first-sheet lookup +
// set_cell_range follow-up. Stubs all three endpoints.
func TestExecute_WorkbookCreate(t *testing.T) {
t.Parallel()
create := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcnBRAND",
"title": "Sales",
},
},
},
}
// Initial fill first reads the workbook structure to resolve the default
// sheet's id (the create response doesn't echo it), then writes.
structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`)
fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`)
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
"--title", "Sales",
"--headers", `["Name","Score"]`,
"--values", `[["alice",95]]`,
}, create, structure, fill)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
ss, _ := data["spreadsheet"].(map[string]interface{})
if ss["spreadsheet_token"] != "shtcnBRAND" {
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
}
if data["initial_fill"] == nil {
t.Errorf("initial_fill missing in envelope")
}
// The fill must target the resolved first sheet, not an empty selector.
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
if fillInput["sheet_id"] != "shtFirst" {
t.Errorf("fill sheet_id = %v, want shtFirst (resolved from workbook structure)", fillInput["sheet_id"])
}
}
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit
// the initial fill (no structure/fill calls fire) and finish with the
// spreadsheet created but no initial_fill — never panic on a nil fill map.
func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, flag, val string }{
{"empty values", "--values", "[]"},
{"empty headers", "--headers", "[]"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
create := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{"spreadsheet_token": "shtNEW", "title": "X"},
},
},
}
// Only the create stub is provided: an empty array must skip the fill
// entirely, so no structure/fill call fires (and no nil-map panic).
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", tc.flag, tc.val}, create)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
if data["initial_fill"] != nil {
t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"])
}
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])
}
})
}
}
// TestExecute_WorkbookCreate_FillFailureKeepsToken locks the partial-success
// contract: when the spreadsheet is created but the follow-up fill can't resolve
// its first sheet, the error must be structured and retain spreadsheet_token so
// the caller can recover instead of orphaning the new workbook.
func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
t.Parallel()
create := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{"spreadsheet_token": "shtNEW", "title": "X"},
},
},
}
// Structure comes back with no sheets, so lookupFirstSheetID fails AFTER the
// spreadsheet already exists — exercising the partial-success path.
structure := toolOutputStub("shtNEW", "read", `{"sheets":[]}`)
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", "--values", `[["a"]]`}, create, structure)
if err == nil {
t.Fatalf("expected a partial-success error; got nil\nout=%s", out)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("error type = %T, want *output.ExitError (structured)", err)
}
if exitErr.Detail == nil {
t.Fatal("ExitError.Detail is nil; want structured detail carrying the token")
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["spreadsheet_token"] != "shtNEW" {
t.Errorf("detail.spreadsheet_token = %v, want shtNEW (must survive the fill failure)", detail["spreadsheet_token"])
}
}
// TestExecute_DimMove covers the native v3 move_dimension call. CLI's
// --source-range "1:3" (1-based inclusive) is parsed into v3's
// source.{start_index=0,end_index=2} (0-based inclusive); --target "11" is
// parsed into destination_index=10.
func TestExecute_DimMove(t *testing.T) {
t.Parallel()
move := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets/" + testToken + "/sheets/" + testSheetID + "/move_dimension",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"moved": true},
},
}
_, err := runShortcutWithStubs(t, DimMove, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--source-range", "1:3", "--target", "11",
}, move)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
body := decodeRawEnvelopeBody(t, move.CapturedBody)
src, _ := body["source"].(map[string]interface{})
if src["start_index"].(float64) != 0 || src["end_index"].(float64) != 2 {
t.Errorf("indices = (%v,%v), want (0,2) — 0-based inclusive", src["start_index"], src["end_index"])
}
if body["destination_index"].(float64) != 10 {
t.Errorf("destination_index = %v, want 10", body["destination_index"])
}
}
// TestExecute_ChartCreate covers the object-CRUD factory's create path.
func TestExecute_ChartCreate(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"chart_id":"chartNEW"}`)
out, err := runShortcutWithStubs(t, ChartCreate, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--properties", `{"type":"line","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`,
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
data := decodeEnvelopeData(t, out)
if data["chart_id"] != "chartNEW" {
t.Errorf("chart_id = %v", data["chart_id"])
}
}
// TestExecute_SheetCreate hits the workbook write path with all four
// optional flags so the input builder + callTool wiring is exercised.
func TestExecute_SheetCreate(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"sheet_id":"sh99","sheet_name":"Q4","index":2}`)
out, err := runShortcutWithStubs(t, SheetCreate, []string{
"--url", testURL,
"--title", "Q4",
"--index", "2",
"--row-count", "300",
"--col-count", "12",
}, stub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "modify_workbook_structure")
if input["operation"] != "create" || input["sheet_name"] != "Q4" {
t.Errorf("input shape wrong: %#v", input)
}
if input["rows"].(float64) != 300 || input["columns"].(float64) != 12 {
t.Errorf("dimensions = (%v, %v), want (300, 12)", input["rows"], input["columns"])
}
}
// TestExecute_RangeSort exercises the sort_conditions JSON parsing
// alongside the boolean has_header.
func TestExecute_RangeSort(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"sorted":true}`)
_, err := runShortcutWithStubs(t, RangeSort, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:D50",
"--has-header",
"--sort-keys", `[{"column":"B","ascending":true}]`,
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "transform_range")
if input["operation"] != "sort" || input["has_header"] != true {
t.Errorf("input wrong: %#v", input)
}
conds, _ := input["sort_conditions"].([]interface{})
if len(conds) != 1 {
t.Errorf("sort_conditions len = %d", len(conds))
}
}
// decodeRawEnvelopeBody parses the raw JSON request body captured by an
// httpmock stub. Used by execute tests to inspect what the CLI sent on
// the wire (vs. dry-run tests that render the body up-front).
func decodeRawEnvelopeBody(t *testing.T, raw []byte) map[string]interface{} {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("captured body parse error: %v\nraw=%s", err, string(raw))
}
return body
}

View File

@@ -1,82 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"fmt"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── flag definitions, sourced from sheet-skill-spec ───────────────────
//
// data/flag-defs.json is the canonical, full definition of every CLI flag
// (name, type, default, desc, enum, input, hidden, required, kind),
// generated by sheet-skill-spec's sync script. The sync script also emits
// flag_defs_gen.go — the compiled `flagDefs` map — so command startup pays
// no JSON unmarshal (the parse cost used to land on every CLI invocation,
// sheets or not). We build each shortcut's []common.Flag from flagDefs at
// assembly time, so flag metadata never has to be hand-written in Go.
//
// Flags with kind == "system" (--dry-run, --yes, ...) are NOT materialized
// here: the framework auto-injects them based on Risk / DryRun / HasFormat.
// Do not hand-edit flag_defs_gen.go or data/flag-defs.json; regenerate via
// the sync script. flag_defs_gen_test.go guards the two against drift.
type flagDef struct {
Name string `json:"name"`
Kind string `json:"kind"` // "public" | "own" | "system"
Type string `json:"type"` // string | bool | int | int64 | float64 | string_array | string_slice
Required string `json:"required"` // "required" | "optional" | "xor"
Desc string `json:"desc"`
Default string `json:"default"`
Hidden bool `json:"hidden"`
Enum []string `json:"enum"`
Input []string `json:"input"`
}
type commandDef struct {
Risk string `json:"risk"`
Flags []flagDef `json:"flags"`
}
// loadFlagDefs returns the compiled flag definitions (flag_defs_gen.go).
// The error return is always nil; it is retained so existing call sites that
// handled a parse error keep compiling. There is no longer a runtime parse.
func loadFlagDefs() (map[string]commandDef, error) {
return flagDefs, nil
}
// flagsFor builds the []common.Flag for a shortcut command directly from
// flag-defs.json. System-kind flags are skipped (the framework injects
// them). Panics if the command is absent or the JSON is malformed — this
// is a build-time data contract, so a missing entry is a programming error
// surfaced loudly at startup rather than a silent empty flag set.
func flagsFor(command string) []common.Flag {
defs, err := loadFlagDefs()
if err != nil {
panic(fmt.Sprintf("sheets: %v", err))
}
spec, ok := defs[command]
if !ok {
panic(fmt.Sprintf("sheets: no flag-defs.json entry for %q", command))
}
out := make([]common.Flag, 0, len(spec.Flags))
for _, df := range spec.Flags {
if df.Kind == "system" {
continue
}
out = append(out, common.Flag{
Name: df.Name,
Type: df.Type,
Default: df.Default,
Desc: df.Desc,
Hidden: df.Hidden,
Required: df.Required == "required",
Enum: df.Enum,
Input: df.Input,
})
}
return out
}

View File

@@ -1,988 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Code generated from data/flag-defs.json; DO NOT EDIT.
package sheets
// flagDefs is the compiled form of data/flag-defs.json — every CLI flag's
// metadata for every shortcut, emitted as a Go literal so command startup
// pays no JSON unmarshal (see flag_defs.go). Do not hand-edit; regenerate
// with `go generate ./shortcuts/sheets/...` after data/flag-defs.json
// changes.
var flagDefs = map[string]commandDef{
"+batch-update": {
Risk: "high-risk-write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", Input: []string{"file", "stdin"}},
{Name: "continue-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template for each sub-operation; no network side effects"},
},
},
"+cells-batch-clear": {
Risk: "high-risk-write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-batch-set-style": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
{Name: "font-line", Kind: "own", Type: "string", Required: "optional", Desc: "Font line style", Enum: []string{"none", "underline", "line-through"}},
{Name: "horizontal-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Horizontal alignment", Enum: []string{"left", "center", "right"}},
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON (same shape as in +cells-set-style)", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-clear": {
Risk: "high-risk-write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to clear (A1 notation)"},
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); clear is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-merge": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
{Name: "merge-type", Kind: "own", Type: "string", Required: "optional", Desc: "Merge direction (`+cells-merge` only)", Default: "all", Enum: []string{"all", "rows", "columns"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-replace": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find for replacement"},
{Name: "replacement", Kind: "own", Type: "string", Required: "required", Desc: "Replacement text; pass empty string `\"\"` to delete matched content"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Replace range (A1 notation); whole sheet when omitted"},
{Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"},
{Name: "match-entire-cell", Kind: "own", Type: "bool", Required: "optional", Desc: "Match the entire cell content"},
{Name: "regex", Kind: "own", Type: "bool", Required: "optional", Desc: "Interpret `--find` as a regex pattern"},
{Name: "include-formulas", Kind: "own", Type: "bool", Required: "optional", Desc: "Also replace within formula text"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Required preflight: outputs `would_replace_count` for user confirmation before the actual replace"},
},
},
"+cells-search": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find (interpreted as regex when `--regex` is set)"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Search range (A1 notation); whole sheet when omitted"},
{Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"},
{Name: "match-entire-cell", Kind: "own", Type: "bool", Required: "optional", Desc: "Match the entire cell content"},
{Name: "regex", Kind: "own", Type: "bool", Required: "optional", Desc: "Interpret `--find` as a regex pattern"},
{Name: "include-formulas", Kind: "own", Type: "bool", Required: "optional", Desc: "Also search within formula text"},
{Name: "max-matches", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 5000", Default: "5000", Hidden: true},
{Name: "offset", Kind: "own", Type: "int", Required: "optional", Desc: "Skip the first N matches (for pagination); default 0", Default: "0"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-set": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Write range (A1 notation)"},
{Name: "cells", Kind: "own", Type: "string", Required: "required", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", Default: "true"},
{Name: "max-cells", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 50000", Default: "50000", Hidden: true},
{Name: "copy-to-range", Kind: "own", Type: "string", Required: "optional", Desc: "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-set-image": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target cell (A1 notation; must be a single cell, e.g. `A1`; start and end must be identical)"},
{Name: "image", Kind: "own", Type: "string", Required: "required", Desc: "Local image path (PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC)"},
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Image file name (with extension); defaults to the basename of `--image`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-set-style": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
{Name: "font-line", Kind: "own", Type: "string", Required: "optional", Desc: "Font line style", Enum: []string{"none", "underline", "line-through"}},
{Name: "horizontal-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Horizontal alignment", Enum: []string{"left", "center", "right"}},
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON: `{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-unmerge": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+chart-create": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
"+chart-delete": {
Risk: "high-risk-write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+chart-list": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter to a single chart reference_id"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+chart-update": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cols-resize": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)", Enum: []string{"pixel", "standard"}},
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise", Default: "0"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cond-format-create": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON: `style` (required, applied on match), `attrs?` (rule-type-dependent params), `has_ref?`. `rule_type` and `ranges` are separate flags", Input: []string{"file", "stdin"}},
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cond-format-delete": {
Risk: "high-risk-write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cond-format-list": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "rule-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by rule id"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cond-format-update": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON, same shape as `+cond-format-create --properties`; update overwrites the entire rule", Input: []string{"file", "stdin"}},
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+csv-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
},
},
"+csv-put": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}},
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dim-delete": {
Risk: "high-risk-write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dim-freeze": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dimension", Kind: "own", Type: "string", Required: "required", Desc: "Dimension (row or column)", Enum: []string{"row", "column"}},
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Freeze the first N rows/columns; pass 0 to unfreeze"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dim-group": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Nesting level for grouping; default 1", Default: "1"},
{Name: "group-state", Kind: "own", Type: "string", Required: "optional", Desc: "Initial group expand state", Default: "expand", Enum: []string{"expand", "fold"}},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to group; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dim-hide": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to hide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dim-insert": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", Default: "none", Enum: []string{"before", "after", "none"}},
{Name: "position", Kind: "own", Type: "string", Required: "required", Desc: "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position"},
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Number of rows/columns to insert (must be > 0)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dim-move": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source row/column closed range to move; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "target", Kind: "own", Type: "string", Required: "required", Desc: "Destination position (the moved rows/columns are placed *before* this position); rows use 1-based row number like `12`, columns use column letter like `H`. Must match the dimension of --source-range"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dim-ungroup": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dim-unhide": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to unhide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dropdown-delete": {
Risk: "high-risk-write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dropdown-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range in A1 notation, e.g. `A2:A100` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dropdown-set": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A2:A100`)"},
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select; default `false`"},
{Name: "highlight", Kind: "own", Type: "bool", Required: "optional", Desc: "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`."},
{Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dropdown-update": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
{Name: "highlight", Kind: "own", Type: "bool", Required: "optional", Desc: "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`."},
{Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+filter-create": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Filter range (A1 notation, including header row, e.g. `A1:F1000`); do not duplicate the range field inside `--properties`"},
{Name: "properties", Kind: "own", Type: "string", Required: "optional", Desc: "Filter rule JSON: `rules` (per-column rule array), `filtered_columns?` (active column index hint). The flag is optional overall — if provided, `rules` must be non-empty; if omitted, an empty filter is created on `--range` (no column conditions). `range` is a separate flag (do not duplicate inside this JSON)", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+filter-delete": {
Risk: "high-risk-write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+filter-list": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+filter-update": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter rule JSON: `rules` and `filtered_columns?`; update overwrites the entire rule set (pass `rules: []` to clear). `range` is a separate flag", Input: []string{"file", "stdin"}},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+filter-view-create": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+filter-view-delete": {
Risk: "high-risk-write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+filter-view-list": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "view-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by filter-view reference_id (returns the matching single view)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+filter-view-update": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?`, `filtered_columns?`; update overwrites the entire rule set (read back with `+filter-view-list` first, then patch; pass `rules: []` to clear). `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; omit to keep the current range on update"},
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+float-image-create": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"},
{Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"},
{Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"},
{Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"},
{Name: "size-height", Kind: "own", Type: "int", Required: "required", Desc: "Image height in pixels"},
{Name: "offset-row", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor row, on top of `--position-row`"},
{Name: "offset-col", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor column, on top of `--position-col`"},
{Name: "z-index", Kind: "own", Type: "int", Required: "optional", Desc: "Image z-index controlling stacking order"},
{Name: "image", Kind: "own", Type: "string", Required: "xor", Desc: "Local image path; the CLI uploads it as a sheet_image and uses the returned file_token (XOR with --image-token / --image-uri)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+float-image-delete": {
Risk: "high-risk-write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+float-image-list": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "float-image-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id; lists all float images on the sheet when omitted"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+float-image-update": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"},
{Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"},
{Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"},
{Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"},
{Name: "size-height", Kind: "own", Type: "int", Required: "required", Desc: "Image height in pixels"},
{Name: "offset-row", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor row, on top of `--position-row`"},
{Name: "offset-col", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor column, on top of `--position-col`"},
{Name: "z-index", Kind: "own", Type: "int", Required: "optional", Desc: "Image z-index controlling stacking order"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+pivot-create": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: {\"rows\":[...],\"columns\":[...],\"values\":[...],\"filters\":[...],\"show_row_grand_total\":true,\"show_col_grand_total\":true} (data source goes through --source; do not put source here)", Input: []string{"file", "stdin"}},
{Name: "target-position", Kind: "own", Type: "string", Required: "optional", Desc: "Top-left cell within the target sub-sheet (A1 notation, e.g. `A1`); maps to the top-level `target_position`, default `A1` (not sent when the value is A1). It and `--range` both express placement but map to different wire fields — avoid passing conflicting values for both.", Default: "A1"},
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "xor", Desc: "Reference_id of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-name`; takes priority when both given; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100."},
{Name: "target-sheet-name", Kind: "own", Type: "string", Required: "xor", Desc: "Name of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-id`; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100."},
{Name: "source", Kind: "own", Type: "string", Required: "required", Desc: "Pivot table source range (A1 notation; format `'SheetName'!StartCell:EndCell`, e.g. `'Sheet1'!A1:D100`)"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Pivot table top-left placement (single A1 value, e.g. `F1`; create only), maps to `properties.range`; placed at the top-left of the target sub-sheet (a newly created one by default) when omitted. It and `--target-position` both express placement but map to different wire fields — avoid passing conflicting values for both."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+pivot-delete": {
Risk: "high-risk-write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+pivot-list": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+pivot-update": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete pivot config (read back with `+pivot-list --pivot-table-id <id>` first, then patch)", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+range-copy": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
{Name: "paste-type", Kind: "own", Type: "string", Required: "optional", Desc: "Paste content type (`+range-copy` only)", Default: "all", Enum: []string{"values", "formulas", "formats", "all"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+range-fill": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Fill template range (seed cells for the series)"},
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination fill range (A1 notation)"},
{Name: "series-type", Kind: "own", Type: "string", Required: "optional", Desc: "Fill series type", Default: "auto", Enum: []string{"auto", "linear", "growth", "date", "copy"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+range-move": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+range-sort": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Sort range (A1 notation; whether the header is included depends on `--has-header`)"},
{Name: "sort-keys", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: `[{\"column\":\"<col letter>\",\"ascending\":<bool>}, ...]`", Input: []string{"file", "stdin"}},
{Name: "has-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as a header and exclude from sort; default `false`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+rows-resize": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)", Enum: []string{"pixel", "standard", "auto"}},
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise", Default: "0"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-copy": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Copy title; auto-generated by the server when omitted"},
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position for the copy (0-based); appended to the end when omitted", Default: "-1"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-create": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"},
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; appended to the end when omitted", Default: "-1"},
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-delete": {
Risk: "high-risk-write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-hide": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-hide-gridline": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-info": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated structure info categories to return", Enum: []string{"merges", "row_heights", "col_widths", "hidden_rows", "hidden_cols", "groups", "frozen"}},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Limit structure info to this A1 range; whole sheet when omitted"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-move": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", Default: "-1"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-rename": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New title"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-set-tab-color": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "color", Kind: "own", Type: "string", Required: "required", Desc: "Hex color like `#FF0000`; pass empty string `\"\"` to clear"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-show-gridline": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-unhide": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sparkline-create": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config (shared style), sparklines (array of mini-charts)}`; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sparkline-delete": {
Risk: "high-risk-write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sparkline-list": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "group-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by group_id"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sparkline-update": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config, sparklines}`; read back with `+sparkline-list --group-id <id>` first, then patch; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+table-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet current region"},
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+table-put": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"},
{Name: "sheets", Kind: "own", Type: "string", Required: "required", Desc: "Typed table payload as JSON: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool", Input: []string{"file", "stdin"}},
{Name: "header-style", Kind: "own", Type: "bool", Required: "optional", Desc: "Bold the header row written from column names (default true)", Default: "true"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+undo": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "steps", Kind: "own", Type: "int", Required: "optional", Desc: "Undo the most recent N edits made through this CLI link (default 1); one step = one prior write call", Default: "1"},
{Name: "rev", Kind: "own", Type: "int", Required: "optional", Desc: "Undo anchor: the document revision returned by a prior write's response (`data.revision`). Omit to undo the latest edit. Doubles as an optimistic-concurrency check — rejected if the document has moved past this revision"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-create": {
Risk: "write",
Flags: []flagDef{
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
{Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}},
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool. Mutually exclusive with --headers/--values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
{Name: "header-style", Kind: "own", Type: "bool", Required: "optional", Desc: "Bold the typed header row (only with --sheets; default true)", Default: "true"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-export": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "file-extension", Kind: "own", Type: "string", Required: "optional", Desc: "Export file format; `csv` mode requires `--sheet-id`", Default: "xlsx", Enum: []string{"xlsx", "csv"}},
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Required only in csv mode: which sheet to export as CSV. This is a `+workbook-export`-specific flag, unrelated to the common four-tuple sheet locator (this shortcut does not accept the common sheet locator)"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path; export is triggered but not downloaded when omitted"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-import": {
Risk: "write",
Flags: []flagDef{
{Name: "file", Kind: "own", Type: "string", Required: "required", Desc: "Local file path (.xlsx / .xls / .csv)"},
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; imported to the cloud drive root when omitted"},
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Imported spreadsheet name; defaults to the local file name without its extension"},
},
},
"+workbook-info": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
}

View File

@@ -1,33 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
_ "embed"
"encoding/json"
"reflect"
"testing"
)
// flagDefsJSONForTest embeds the source data only in tests; production code
// reads the compiled flagDefs map (flag_defs_gen.go) and never unmarshals.
//
//go:embed data/flag-defs.json
var flagDefsJSONForTest []byte
// TestFlagDefsGen_MatchesJSON guards against drift between the compiled
// flagDefs map (flag_defs_gen.go) and its source data/flag-defs.json: if the
// JSON is regenerated without re-running the codegen (or vice versa), this
// fails. This equivalence is exactly what lets production code skip the
// runtime unmarshal.
func TestFlagDefsGen_MatchesJSON(t *testing.T) {
t.Parallel()
var fromJSON map[string]commandDef
if err := json.Unmarshal(flagDefsJSONForTest, &fromJSON); err != nil {
t.Fatalf("unmarshal flag-defs.json: %v", err)
}
if !reflect.DeepEqual(fromJSON, flagDefs) {
t.Error("compiled flagDefs differs from data/flag-defs.json; regenerate flag_defs_gen.go")
}
}

View File

@@ -1,142 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
// TestFlagDefs_EmbedParses asserts the embedded flag-defs.json blob is valid
// JSON with at least one command entry.
func TestFlagDefs_EmbedParses(t *testing.T) {
t.Parallel()
defs, err := loadFlagDefs()
if err != nil {
t.Fatalf("loadFlagDefs error: %v", err)
}
if len(defs) == 0 {
t.Fatal("flag-defs.json has no command entries")
}
}
// TestFlagsFor_SkipsSystemFlags verifies system-kind flags (--dry-run, --yes)
// are never materialized into a shortcut's Flags slice — the framework injects
// those based on Risk / DryRun.
func TestFlagsFor_SkipsSystemFlags(t *testing.T) {
t.Parallel()
for _, cmd := range []string{"+sheet-delete", "+batch-update", "+csv-get"} {
for _, f := range flagsFor(cmd) {
if f.Name == "dry-run" || f.Name == "yes" {
t.Errorf("%s: system flag --%s leaked into Flags", cmd, f.Name)
}
}
}
}
// TestFlagsFor_MapsAllFields spot-checks that name/type/default/enum/input/
// required/hidden are carried over from the JSON correctly.
func TestFlagsFor_MapsAllFields(t *testing.T) {
t.Parallel()
byName := func(cmd, name string) *common.Flag {
flags := flagsFor(cmd)
for i := range flags {
if flags[i].Name == name {
return &flags[i]
}
}
return nil
}
// enum + default
rt := byName("+dim-insert", "inherit-style")
if rt == nil || len(rt.Enum) != 3 || rt.Default != "none" {
t.Errorf("+dim-insert --inherit-style not mapped: %+v", rt)
}
// required
title := byName("+sheet-create", "title")
if title == nil || !title.Required {
t.Errorf("+sheet-create --title should be required: %+v", title)
}
// xor is NOT cobra-required (enforced by Validate hooks)
url := byName("+sheet-create", "url")
if url == nil || url.Required {
t.Errorf("+sheet-create --url should not be cobra-required: %+v", url)
}
// hidden + int default
cap := byName("+cells-get", "max-chars")
if cap == nil || !cap.Hidden || cap.Default != "200000" {
t.Errorf("+cells-get --max-chars not mapped: %+v", cap)
}
// input sources
cells := byName("+cells-set", "cells")
if cells == nil || len(cells.Input) != 2 {
t.Errorf("+cells-set --cells should support file+stdin: %+v", cells)
}
// float64 type
fs := byName("+cells-set-style", "font-size")
if fs == nil || fs.Type != "float64" {
t.Errorf("+cells-set-style --font-size should be float64: %+v", fs)
}
}
// TestFlagsFor_EveryRegisteredCommandHasDefs ensures every shortcut returned by
// Shortcuts() has a flag-defs.json entry and that its flags match the JSON's
// non-system flags exactly (name + type + required + default + hidden). This is
// the contract that lets shortcuts drop hand-written flag literals.
func TestFlagsFor_EveryRegisteredCommandHasDefs(t *testing.T) {
t.Parallel()
defs, err := loadFlagDefs()
if err != nil {
t.Fatal(err)
}
for _, s := range Shortcuts() {
spec, ok := defs[s.Command]
if !ok {
t.Errorf("%s has no flag-defs.json entry", s.Command)
continue
}
want := map[string]flagDef{}
for _, df := range spec.Flags {
if df.Kind != "system" {
want[df.Name] = df
}
}
got := map[string]bool{}
for _, f := range s.Flags {
got[f.Name] = true
df, ok := want[f.Name]
if !ok {
t.Errorf("%s --%s present in Go but not in JSON (non-system)", s.Command, f.Name)
continue
}
ft := f.Type
if ft == "" {
ft = "string"
}
jt := df.Type
if jt == "" {
jt = "string"
}
if ft != jt {
t.Errorf("%s --%s type: go=%s json=%s", s.Command, f.Name, ft, jt)
}
if f.Required != (df.Required == "required") {
t.Errorf("%s --%s required: go=%v json=%s", s.Command, f.Name, f.Required, df.Required)
}
if f.Default != df.Default {
t.Errorf("%s --%s default: go=%q json=%q", s.Command, f.Name, f.Default, df.Default)
}
if f.Hidden != df.Hidden {
t.Errorf("%s --%s hidden: go=%v json=%v", s.Command, f.Name, f.Hidden, df.Hidden)
}
}
for name := range want {
if !got[name] {
t.Errorf("%s --%s in JSON but missing from Go Flags", s.Command, name)
}
}
}
}

View File

@@ -1,124 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
_ "embed"
"encoding/json"
"fmt"
"sort"
"sync"
)
// ─── --print-schema runtime introspection ─────────────────────────────
//
// Composite JSON flags (--cells, --properties, --operations, --border-styles,
// --sort-keys) carry non-trivial structured payloads. Reference docs cover
// the top-level fields but agents often need the full JSON Schema to
// generate valid input.
//
// To serve that need without forcing every caller to fetch external docs,
// the spec repo ships a compact `flag-schemas.json` that extracts just the
// schema subtree corresponding to each (shortcut, flag) pair. We embed
// that artifact at compile time so `lark-cli sheets <shortcut>
// --print-schema --flag-name <name>` runs entirely locally.
//
// The artifact is generated by sheet-skill-spec's
// scripts/sync_to_consumers.mjs from canonical-spec/cli-flag-schema-map.json
// + tool-schemas/mcp-tools.json. Do not hand-edit data/flag-schemas.json;
// regenerate via the sync script.
//go:embed data/flag-schemas.json
var flagSchemasJSON []byte
// flagSchemaIndex parses lazily on first access; failures are surfaced
// as errors from the lookup helper rather than panicking at init time.
type flagSchemaIndex struct {
SchemaVersion string `json:"schema_version"`
Flags map[string]map[string]json.RawMessage `json:"flags"`
}
// loadFlagSchemas is sync.Once-guarded so concurrent first access from
// parallel goroutines (e.g. parallel unit tests, parallel shortcut
// invocations) doesn't race on the lazy parse.
var (
flagSchemasOnce sync.Once
parsedFlagSchemas *flagSchemaIndex
parseFlagErr error
)
func loadFlagSchemas() (*flagSchemaIndex, error) {
flagSchemasOnce.Do(func() {
var idx flagSchemaIndex
if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil {
parseFlagErr = fmt.Errorf("flag-schemas.json: %w", err)
return
}
if idx.Flags == nil {
idx.Flags = map[string]map[string]json.RawMessage{}
}
parsedFlagSchemas = &idx
})
return parsedFlagSchemas, parseFlagErr
}
// commandsWithFlagSchema returns the set of shortcut commands that have
// at least one introspectable flag. Used by Shortcuts() to decide which
// shortcuts to wire PrintFlagSchema into.
func commandsWithFlagSchema() map[string]struct{} {
idx, err := loadFlagSchemas()
if err != nil || idx == nil {
return nil
}
out := make(map[string]struct{}, len(idx.Flags))
for cmd := range idx.Flags {
out[cmd] = struct{}{}
}
return out
}
// printFlagSchemaFor returns a PrintFlagSchema closure bound to the given
// shortcut command. When flagName == "" the closure returns a JSON
// listing of introspectable flags; otherwise it returns the schema
// subtree JSON for the named flag, or an error if the flag is not
// registered.
func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
return func(flagName string) ([]byte, error) {
idx, err := loadFlagSchemas()
if err != nil {
return nil, err
}
entry, ok := idx.Flags[command]
if !ok || len(entry) == 0 {
return nil, fmt.Errorf("no JSON Schema registered for %s", command)
}
if flagName == "" {
flags := make([]string, 0, len(entry))
for f := range entry {
flags = append(flags, f)
}
sort.Strings(flags)
return json.MarshalIndent(map[string]interface{}{
"shortcut": command,
"introspectable_flags": flags,
"hint": "run again with --flag-name <name> to dump the JSON Schema for that flag",
}, "", " ")
}
schema, ok := entry[flagName]
if !ok {
flags := make([]string, 0, len(entry))
for f := range entry {
flags = append(flags, f)
}
sort.Strings(flags)
return nil, fmt.Errorf("no JSON Schema registered for %s --%s; available: %v", command, flagName, flags)
}
// Reformat for readability — schema files store compact JSON.
var pretty interface{}
if err := json.Unmarshal(schema, &pretty); err != nil {
return nil, err
}
return json.MarshalIndent(pretty, "", " ")
}
}

View File

@@ -1,209 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
)
// TestFlagSchemas_EmbedParses asserts the synced flag-schemas.json
// embedded blob is valid JSON and has at least one shortcut/flag entry.
// If sync_to_consumers.mjs ever ships an empty or broken artifact, this
// catches it at build time of the test binary.
func TestFlagSchemas_EmbedParses(t *testing.T) {
t.Parallel()
idx, err := loadFlagSchemas()
if err != nil {
t.Fatalf("loadFlagSchemas error: %v", err)
}
if idx == nil || len(idx.Flags) == 0 {
t.Fatalf("flag-schemas.json has no entries")
}
if idx.SchemaVersion == "" {
t.Errorf("schema_version missing")
}
// Spot-check a couple of canonical entries we know upstream guarantees.
for _, want := range []string{"+cells-set", "+chart-create", "+batch-update"} {
if _, ok := idx.Flags[want]; !ok {
t.Errorf("missing shortcut entry %q (regenerate via sheet-skill-spec/scripts/sync_to_consumers.mjs)", want)
}
}
}
// TestPrintFlagSchema_ListIntrospectable verifies that calling the
// closure with an empty flag name returns the JSON listing of
// introspectable flags for the shortcut.
func TestPrintFlagSchema_ListIntrospectable(t *testing.T) {
t.Parallel()
out, err := printFlagSchemaFor("+cells-set")("")
if err != nil {
t.Fatalf("err: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("output not JSON: %v\n%s", err, out)
}
if got["shortcut"] != "+cells-set" {
t.Errorf("shortcut = %v, want +cells-set", got["shortcut"])
}
flags, _ := got["introspectable_flags"].([]interface{})
if len(flags) == 0 || flags[0] != "cells" {
t.Errorf("introspectable_flags = %v, want [cells]", flags)
}
}
// TestPrintFlagSchema_NamedFlagReturnsSchemaSubtree verifies a hit on
// (+chart-create, properties) yields a JSON Schema object with the
// expected top-level fields.
func TestPrintFlagSchema_NamedFlagReturnsSchemaSubtree(t *testing.T) {
t.Parallel()
out, err := printFlagSchemaFor("+chart-create")("properties")
if err != nil {
t.Fatalf("err: %v", err)
}
var schema map[string]interface{}
if err := json.Unmarshal(out, &schema); err != nil {
t.Fatalf("output not JSON: %v\n%s", err, out)
}
if schema["type"] != "object" {
t.Errorf("schema.type = %v, want object", schema["type"])
}
if _, ok := schema["properties"]; !ok {
t.Errorf("schema missing nested .properties: keys=%v", keysOf(schema))
}
}
// TestPrintFlagSchema_UnknownFlagListsAvailable confirms the error
// message tells the caller which flags exist for the shortcut.
func TestPrintFlagSchema_UnknownFlagListsAvailable(t *testing.T) {
t.Parallel()
_, err := printFlagSchemaFor("+chart-create")("does-not-exist")
if err == nil {
t.Fatal("expected error for unknown flag, got nil")
}
msg := err.Error()
if !strings.Contains(msg, "+chart-create") || !strings.Contains(msg, "properties") {
t.Errorf("error should mention shortcut + available flags; got %q", msg)
}
}
// TestPrintFlagSchema_UnknownShortcut surfaces a missing shortcut entry.
func TestPrintFlagSchema_UnknownShortcut(t *testing.T) {
t.Parallel()
_, err := printFlagSchemaFor("+not-a-real-shortcut")("")
if err == nil {
t.Fatal("expected error for unknown shortcut")
}
}
// TestShortcuts_AttachesPrintFlagSchema confirms the registration loop
// in Shortcuts() wires PrintFlagSchema onto each shortcut whose command
// has a schema entry, and leaves it nil for shortcuts that don't.
func TestShortcuts_AttachesPrintFlagSchema(t *testing.T) {
t.Parallel()
all := Shortcuts()
withSchema := commandsWithFlagSchema()
for _, s := range all {
_, expected := withSchema[s.Command]
got := s.PrintFlagSchema != nil
if got != expected {
t.Errorf("%s: PrintFlagSchema attached=%v, expected=%v", s.Command, got, expected)
}
}
}
// TestPrintSchema_SystemFlagShortCircuit verifies the framework's
// --print-schema interception: required flags are relaxed, Validate /
// Execute are skipped, and the schema JSON appears on stdout.
func TestPrintSchema_SystemFlagShortCircuit(t *testing.T) {
t.Parallel()
// +cells-set has required --range / --cells / --sheet-id; without
// --print-schema, cobra would reject the call. With --print-schema,
// it should print the schema and exit cleanly. The PrintFlagSchema
// closure is normally attached by Shortcuts(), so we attach it here
// to mirror that registration path.
sc := CellsSet
sc.PrintFlagSchema = printFlagSchemaFor(sc.Command)
stdout, err := runShortcut(t, sc, []string{"--print-schema", "--flag-name", "cells"})
if err != nil {
t.Fatalf("err: %v\nstdout=%s", err, stdout)
}
if !strings.Contains(stdout, "\"type\"") {
t.Errorf("expected JSON Schema with \"type\" key; got=%s", stdout)
}
}
// TestPrintSchema_ListingWhenNoFlagNameGiven exercises the discovery
// path: `--print-schema` without `--flag-name` should list the
// shortcut's introspectable flags as JSON on stdout.
func TestPrintSchema_ListingWhenNoFlagNameGiven(t *testing.T) {
t.Parallel()
sc := CellsSet
sc.PrintFlagSchema = printFlagSchemaFor(sc.Command)
stdout, err := runShortcut(t, sc, []string{"--print-schema"})
if err != nil {
t.Fatalf("err: %v\nstdout=%s", err, stdout)
}
var got map[string]interface{}
if err := json.Unmarshal([]byte(stdout), &got); err != nil {
t.Fatalf("stdout not JSON: %v\n%s", err, stdout)
}
flags, _ := got["introspectable_flags"].([]interface{})
if len(flags) == 0 {
t.Errorf("introspectable_flags empty: %#v", got)
}
}
// TestPrintSchema_SystemFlagAbsentForReadOnlyShortcut ensures we don't
// inject --print-schema onto shortcuts that have no composite flags.
// +workbook-info is read-only and not in the schema map.
func TestPrintSchema_SystemFlagAbsentForReadOnlyShortcut(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, WorkbookInfo, []string{"--url", testURL, "--print-schema"})
if err == nil {
t.Fatal("expected unknown flag error")
}
if !strings.Contains(err.Error(), "unknown flag") {
t.Errorf("expected 'unknown flag'; got %v", err)
}
}
// TestPrintSchema_UnknownFlagNameIsStructured pins issue #6: an unregistered
// --flag-name passed to --print-schema must surface as a structured
// *output.ExitError (type print_schema_error), not a bare error string, so the
// agent-facing introspection path stays machine-parseable.
func TestPrintSchema_UnknownFlagNameIsStructured(t *testing.T) {
t.Parallel()
// PrintFlagSchema is wired during registration (shortcuts.go), not on the
// literal, so replicate that here to make Mount inject the --print-schema /
// --flag-name system flags.
sc := CellsSet
sc.PrintFlagSchema = printFlagSchemaFor(sc.Command)
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--print-schema", "--flag-name", "nonexistent",
})
if err == nil {
t.Fatal("expected an error for --print-schema with an unregistered flag name")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want a structured *output.ExitError", err)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "print_schema_error" {
t.Errorf("error detail = %+v, want type print_schema_error", exitErr.Detail)
}
}
func keysOf(m map[string]interface{}) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}

View File

@@ -1,500 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── schema-driven flag validation ────────────────────────────────────
//
// Composite JSON flags (--properties, --cells, --operations, …) carry
// non-trivial payloads whose shape is already pinned by the embedded
// data/flag-schemas.json (see flag_schema.go). Rather than hand-write
// per-spec validators for type / enum / required / nested checks, every
// such flag is run through validatePropertiesAgainstSchema after the
// shortcut's enhance hook has filled in any flat-flag-derived fields
// (schema describes the *final* tool input, not the raw --properties
// JSON the user typed). Cross-field business rules that JSON Schema
// can't express (e.g. sparkline-update requires sparkline_id per item)
// continue to live in spec.validateUpdateInput.
//
// The rule set is a subset of ai-tools/.../validate-tool-params.ts —
// type, enum, oneOf, required, nested properties, and array items.
// additionalProperties is intentionally lenient: the embedded schema
// is a sub-tree and may not be exhaustive, so rejecting unknown keys
// would be more disruptive than valuable.
// validateParsedJSONFlag validates the just-parsed value of a single
// JSON flag against its embedded schema, if one is registered for the
// (command, flag) pair. Called from parseJSONFlag so every JSON flag
// — sort-keys, options, border-styles, cells, operations, ranges, … —
// is checked at the user-input boundary, in user-input shape.
//
// `properties` is intentionally skipped here: its schema describes the
// *final* tool-input properties (the shape after enhance* hooks
// inject flat-flag-derived fields such as cond-format's rule_type),
// not what the user typed under --properties. The input-builder tail
// validates that one via validateInputAgainstSchema after enhance.
func validateParsedJSONFlag(fv flagView, name string, value interface{}) error {
if fv == nil || value == nil {
return nil
}
if _, skip := parseJSONFlagSkip[name]; skip {
return nil
}
return validateValueAgainstSchema(fv, name, value)
}
// parseJSONFlagSkip lists flag names where parseJSONFlag-time schema
// validation is intentionally bypassed:
//
// - properties: schema describes the *final* tool-input shape (after
// enhance hooks inject flat-flag-derived fields); validated at the
// input-builder tail via validateInputAgainstSchema instead.
// - operations: +batch-update's translator does richer validation
// (allowed-shortcut allow-list, fan-out rejection, …) with more
// actionable error messages than a generic "not in enum [...]"
// would. The translator path stays the source of truth.
var parseJSONFlagSkip = map[string]struct{}{
"properties": {},
"operations": {},
}
// validateValueAgainstSchema is the (command, flag) → schema → check
// pipeline shared by both validateParsedJSONFlag (user shape) and
// validateInputAgainstSchema (wire shape).
func validateValueAgainstSchema(fv flagView, name string, value interface{}) error {
command := fv.Command()
if command == "" {
return nil
}
// Fast path: commands without a registered schema can't fail this check,
// so skip the 256KB flag-schemas.json parse entirely for them.
if _, ok := commandsWithSchema[command]; !ok {
return nil
}
idx, _ := loadFlagSchemas()
if idx == nil {
return nil
}
entry, ok := idx.Flags[command]
if !ok {
return nil
}
raw, ok := entry[name]
if !ok {
return nil
}
var schema schemaProperty
json.Unmarshal(raw, &schema)
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
return common.FlagErrorf("--%s: %s", name, vErr.Error())
}
return nil
}
// validateInputAgainstSchema validates input[flag] for every flag the
// embedded schema registers under the view's shortcut command. Returns
// nil when no schema is registered for the command, or when none of
// the registered flag names appear in `input` (schema describes the
// shape of values when they are present, not which flags must be
// present). Designed to be called at the tail of every input builder
// so wiring up a new shortcut requires only the standard one-line
// invocation, not a per-shortcut validator.
func validateInputAgainstSchema(fv flagView, input map[string]interface{}) error {
if fv == nil || input == nil {
return nil
}
command := fv.Command()
if command == "" {
return nil
}
// Fast path: commands without a registered schema have nothing to
// validate, so skip the 256KB flag-schemas.json parse entirely.
if _, ok := commandsWithSchema[command]; !ok {
return nil
}
idx, _ := loadFlagSchemas()
if idx == nil {
return nil
}
entry, ok := idx.Flags[command]
if !ok || len(entry) == 0 {
return nil
}
// Deterministic order so error messages are stable across runs.
flagNames := make([]string, 0, len(entry))
for name := range entry {
flagNames = append(flagNames, name)
}
sort.Strings(flagNames)
for _, flagName := range flagNames {
if _, skip := inputSchemaSkip[flagName]; skip {
continue
}
// Input keys are wire-style (underscore); schema keys are CLI-style
// (hyphen) — translate before lookup. Flags whose wire form lives
// under a different key (e.g. --sort-keys → sort_conditions) won't
// be found here; they're already validated in user shape via
// parseJSONFlag → validateParsedJSONFlag.
inputKey := strings.ReplaceAll(flagName, "-", "_")
value, present := input[inputKey]
if !present {
continue
}
if err := validateValueAgainstSchema(fv, flagName, value); err != nil {
return err
}
}
return nil
}
// inputSchemaSkip mirrors parseJSONFlagSkip for the input-builder
// tail. Same rationale: bypass schema validation for flags where
// richer translator-side validation owns the contract (operations).
var inputSchemaSkip = map[string]struct{}{
"operations": {},
}
// schemaProperty mirrors the JSON Schema subset used by
// data/flag-schemas.json. Unknown keys (description, …) are dropped —
// they're documentation.
//
// Minimum / Maximum / MinItems / MaxItems use *float64 / *int because
// 0 is a meaningful bound (e.g. chart row >= 0); nil distinguishes
// "no bound declared" from "bound is zero".
//
// AdditionalProperties handles the JSON Schema three-way:
// - absent / true → lenient, any extra key allowed (validator's
// default; matches the file header's "may not be exhaustive"
// stance for schemas that simply don't declare it).
// - false → strict, every extra key rejected.
// - <schema> → extra keys allowed, but each value must validate
// against this schema. Used today for pivot's dynamic
// map<string, array<string>> fields (groups / collapse).
type schemaProperty struct {
Type string `json:"type"`
Nullable bool `json:"nullable"`
Enum []interface{} `json:"enum"`
Properties map[string]*schemaProperty `json:"properties"`
Required []string `json:"required"`
Items *schemaProperty `json:"items"`
OneOf []*schemaProperty `json:"oneOf"`
Minimum *float64 `json:"minimum"`
Maximum *float64 `json:"maximum"`
MinItems *int `json:"minItems"`
MaxItems *int `json:"maxItems"`
AdditionalProperties *additionalProps `json:"additionalProperties"`
}
// additionalProps captures the three JSON Schema forms of
// `additionalProperties`. UnmarshalJSON decodes true / false / object
// into the same struct so callers can branch on (Strict, Schema).
type additionalProps struct {
Strict bool // true when schema declared additionalProperties:false
Schema *schemaProperty // non-nil when declared as an object schema
}
func (a *additionalProps) UnmarshalJSON(data []byte) error {
trimmed := strings.TrimSpace(string(data))
switch trimmed {
case "true":
return nil // lenient — same as absent
case "false":
a.Strict = true
return nil
}
var sub schemaProperty
if err := json.Unmarshal(data, &sub); err != nil {
return err
}
a.Schema = &sub
return nil
}
// validateAgainstSchema recursively checks `value` against `schema`,
// prefixing any failure with the JSON path navigated so far.
func validateAgainstSchema(value interface{}, schema *schemaProperty, path string) error {
if schema == nil {
return nil // defensive — current callers always pass &schema, but
// keeps validator safe for future programmatic construction.
}
if value == nil && schema.Nullable {
return nil
}
if schema.Type != "" {
if !matchesJSONType(value, schema.Type) {
return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value))
}
}
// Numeric bounds — only checked when value is a number (type mismatch
// already reported above). Apply to both `number` and `integer` types.
if num, ok := value.(float64); ok {
if schema.Minimum != nil && num < *schema.Minimum {
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum)
}
if schema.Maximum != nil && num > *schema.Maximum {
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum)
}
}
// Array length bounds — only checked when value is an array.
if arr, ok := value.([]interface{}); ok {
if schema.MinItems != nil && len(arr) < *schema.MinItems {
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems)
}
if schema.MaxItems != nil && len(arr) > *schema.MaxItems {
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems)
}
}
if len(schema.Enum) > 0 {
matched := false
for _, allowed := range schema.Enum {
if jsonEqual(allowed, value) {
matched = true
break
}
}
if !matched {
msg := fmt.Sprintf("%svalue %s is not in enum %s",
pathPrefix(path), formatJSONValue(value), formatEnum(schema.Enum))
if hint := suggestEnumMatch(value, schema.Enum); hint != "" {
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
}
return fmt.Errorf("%s", msg)
}
}
if len(schema.OneOf) > 0 {
matched := false
for _, sub := range schema.OneOf {
if validateAgainstSchema(value, sub, path) == nil {
matched = true
break
}
}
if !matched {
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path))
}
}
// Object-level checks. `required` and `properties` are independent
// per JSON Schema: `required` enforces keys regardless of whether
// the schema also describes their per-key shape via `properties`.
if obj, ok := value.(map[string]interface{}); ok {
for _, key := range schema.Required {
if _, present := obj[key]; !present {
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path))
}
}
if schema.Properties != nil {
keys := make([]string, 0, len(schema.Properties))
for k := range schema.Properties {
keys = append(keys, k)
}
sort.Strings(keys)
for _, key := range keys {
sub := schema.Properties[key]
v, present := obj[key]
if !present {
continue
}
// Case-insensitive enum tolerance: when the value matches an
// allowed enum entry except for casing, rewrite it in place to
// the canonical spelling. The schema lists enums in their
// canonical (lower-case) form, so "SUM" / "COUNTA" would
// otherwise be rejected right here before the request is even
// sent; normalizing kills the whole pivot summarize_by "SUM vs
// sum" class. Genuinely-unknown values still fail below, with
// their own did-you-mean hint.
if sub != nil && len(sub.Enum) > 0 {
if canon := suggestEnumMatch(v, sub.Enum); canon != "" {
obj[key] = canon
v = canon
}
}
child := key
if path != "" {
child = path + "." + key
}
if err := validateAgainstSchema(v, sub, child); err != nil {
return err
}
}
}
// additionalProperties: enforce only when explicitly declared.
// Absent means lenient (matches the file header's stance). Sort
// extras so the first rejection is deterministic across runs.
if schema.AdditionalProperties != nil {
extras := make([]string, 0)
for key := range obj {
if _, declared := schema.Properties[key]; declared {
continue
}
extras = append(extras, key)
}
sort.Strings(extras)
for _, key := range extras {
if schema.AdditionalProperties.Strict {
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
}
if schema.AdditionalProperties.Schema != nil {
child := key
if path != "" {
child = path + "." + key
}
if err := validateAgainstSchema(obj[key], schema.AdditionalProperties.Schema, child); err != nil {
return err
}
}
}
}
}
if schema.Type == "array" && schema.Items != nil {
arr, ok := value.([]interface{})
if !ok {
return nil // type mismatch already reported above.
}
for i, item := range arr {
child := fmt.Sprintf("%s[%d]", path, i)
if err := validateAgainstSchema(item, schema.Items, child); err != nil {
return err
}
}
}
return nil
}
func matchesJSONType(value interface{}, expected string) bool {
switch expected {
case "object":
_, ok := value.(map[string]interface{})
return ok
case "array":
_, ok := value.([]interface{})
return ok
case "string":
_, ok := value.(string)
return ok
case "number":
_, ok := value.(float64)
return ok
case "integer":
f, ok := value.(float64)
return ok && f == float64(int64(f))
case "boolean":
_, ok := value.(bool)
return ok
case "null":
return value == nil
}
return true
}
func jsType(value interface{}) string {
switch value.(type) {
case nil:
return "null"
case map[string]interface{}:
return "object"
case []interface{}:
return "array"
case string:
return "string"
case float64:
return "number"
case bool:
return "boolean"
}
return fmt.Sprintf("%T", value)
}
func jsonEqual(a, b interface{}) bool {
ja, _ := json.Marshal(a)
jb, _ := json.Marshal(b)
return string(ja) == string(jb)
}
// formatJSONValue is the "what you actually passed" half of an enum
// error. Strings get JSON-quoted ("SUM"); everything else (numbers,
// booleans, null, objects, arrays) gets its JSON encoding. Marshal
// failure falls back to %v so we never panic just to format an error.
func formatJSONValue(v interface{}) string {
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
// formatEnum renders the allowed-values list for an enum error. Caps
// the visible entries at enumDisplayLimit so a 50-shortcut enum
// doesn't bury the actual error in a wall of options; the overflow
// hint tells the user how many more exist (and to consult --help /
// --print-schema for the full list).
const enumDisplayLimit = 8
func formatEnum(values []interface{}) string {
if len(values) <= enumDisplayLimit {
return "[" + joinFormatted(values) + "]"
}
shown := values[:enumDisplayLimit]
return fmt.Sprintf("[%s, … (%d more)]", joinFormatted(shown), len(values)-enumDisplayLimit)
}
func joinFormatted(values []interface{}) string {
parts := make([]string, 0, len(values))
for _, v := range values {
parts = append(parts, formatJSONValue(v))
}
return strings.Join(parts, ", ")
}
// suggestEnumMatch returns a "did you mean" candidate when the user's
// value differs from an allowed enum entry only in casing — the most
// common real-world mistake ("SUM" vs "sum", "True" vs "true"). The
// match is restricted to strings; non-string enums (numbers, etc.)
// don't have a casing notion. Returns "" when no near-miss exists.
func suggestEnumMatch(value interface{}, values []interface{}) string {
s, ok := value.(string)
if !ok {
return ""
}
lower := strings.ToLower(s)
for _, v := range values {
if vs, ok := v.(string); ok && strings.ToLower(vs) == lower {
if vs != s { // skip exact-equal (already would have matched).
return vs
}
}
}
return ""
}
func pathPrefix(path string) string {
if path == "" {
return ""
}
return path + ": "
}
func pathOrRoot(path string) string {
if path == "" {
return "(root)"
}
return path
}

View File

@@ -1,589 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
)
// parseSchema is a tiny test helper: take an inline JSON Schema string,
// hand back a *schemaProperty for validateAgainstSchema. Lets test
// cases declare their schema inline rather than hand-building structs.
func parseSchema(t *testing.T, raw string) *schemaProperty {
t.Helper()
var s schemaProperty
if err := json.Unmarshal([]byte(raw), &s); err != nil {
t.Fatalf("bad inline schema %q: %v", raw, err)
}
return &s
}
// parseValue decodes a JSON literal the same way encoding/json gives
// validateAgainstSchema its input (numbers → float64, objects →
// map[string]interface{}, arrays → []interface{}).
func parseValue(t *testing.T, raw string) interface{} {
t.Helper()
var v interface{}
if err := json.Unmarshal([]byte(raw), &v); err != nil {
t.Fatalf("bad inline value %q: %v", raw, err)
}
return v
}
// TestValidateAgainstSchema_EnumCaseNormalization pins the case-insensitive
// enum tolerance: a value matching an allowed enum entry except for casing is
// rewritten in place to the canonical spelling (so the case-sensitive backend
// accepts it), while genuinely-unknown values still fail. Only fires for enum
// fields nested in an object/array — the pivot values[].summarize_by path.
func TestValidateAgainstSchema_EnumCaseNormalization(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{"type":"object","properties":{"summarize_by":{"type":"string","enum":["sum","count","average"]}}}`)
t.Run("rewrites case-only mismatch in place", func(t *testing.T) {
obj := map[string]interface{}{"summarize_by": "SUM"}
if err := validateAgainstSchema(obj, schema, ""); err != nil {
t.Fatalf("case-only value should pass after normalization, got: %v", err)
}
if got := obj["summarize_by"]; got != "sum" {
t.Errorf("summarize_by = %q, want normalized %q", got, "sum")
}
})
t.Run("leaves exact match untouched", func(t *testing.T) {
obj := map[string]interface{}{"summarize_by": "count"}
if err := validateAgainstSchema(obj, schema, ""); err != nil {
t.Fatalf("exact match should pass: %v", err)
}
if got := obj["summarize_by"]; got != "count" {
t.Errorf("exact value mutated to %q", got)
}
})
t.Run("unknown value still fails", func(t *testing.T) {
obj := map[string]interface{}{"summarize_by": "COUNTA"}
if err := validateAgainstSchema(obj, schema, ""); err == nil {
t.Fatal("unknown enum value should fail")
} else if !strings.Contains(err.Error(), "not in enum") {
t.Errorf("want enum error, got: %v", err)
}
})
t.Run("normalizes inside array-of-objects (values[] shape)", func(t *testing.T) {
arrSchema := parseSchema(t, `{"type":"array","items":{"type":"object","properties":{"summarize_by":{"type":"string","enum":["sum","count"]}}}}`)
arr := []interface{}{
map[string]interface{}{"summarize_by": "Sum"},
map[string]interface{}{"summarize_by": "COUNT"},
}
if err := validateAgainstSchema(arr, arrSchema, ""); err != nil {
t.Fatalf("array case normalization failed: %v", err)
}
if got := arr[0].(map[string]interface{})["summarize_by"]; got != "sum" {
t.Errorf("arr[0] summarize_by = %q, want sum", got)
}
if got := arr[1].(map[string]interface{})["summarize_by"]; got != "count" {
t.Errorf("arr[1] summarize_by = %q, want count", got)
}
})
}
// TestValidateAgainstSchema is the validator's contract test: every
// supported keyword (type, enum, oneOf, required, nested properties,
// array items, nullable, minimum/maximum, minItems/maxItems) gets a
// pass + fail case, and the failure message is asserted to mention
// the JSON path and the violated constraint. Together these pin the
// validator's behaviour without going through any shortcut wiring.
func TestValidateAgainstSchema(t *testing.T) {
t.Parallel()
cases := []struct {
name string
schema string
value string
wantOK bool
wantInErr string // substring required in error message when !wantOK
}{
// ─── type ─────────────────────────────────────────────────────
{"type string ok", `{"type":"string"}`, `"hi"`, true, ""},
{"type string wrong", `{"type":"string"}`, `42`, false, `expected type "string"`},
{"type number ok", `{"type":"number"}`, `3.14`, true, ""},
{"type number wrong", `{"type":"number"}`, `"x"`, false, `got "string"`},
{"type integer ok", `{"type":"integer"}`, `5`, true, ""},
{"type integer fractional rejected", `{"type":"integer"}`, `5.5`, false, `expected type "integer"`},
{"type boolean ok", `{"type":"boolean"}`, `true`, true, ""},
{"type array ok", `{"type":"array"}`, `[1,2]`, true, ""},
{"type object ok", `{"type":"object"}`, `{"a":1}`, true, ""},
// ─── nullable short-circuit ───────────────────────────────────
{"nullable null accepted", `{"type":"string","nullable":true}`, `null`, true, ""},
{"nullable schema still type-checks non-null", `{"type":"string","nullable":true}`, `42`, false, `expected type "string"`},
{"nullable schema accepts matching type", `{"type":"string","nullable":true}`, `"x"`, true, ""},
{"null rejected when nullable not set", `{"type":"string"}`, `null`, false, `expected type "string"`},
// ─── enum ────────────────────────────────────────────────────
{"enum hit", `{"type":"string","enum":["asc","desc"]}`, `"asc"`, true, ""},
{"enum miss", `{"type":"string","enum":["asc","desc"]}`, `"sideways"`, false, `not in enum ["asc", "desc"]`},
// ─── oneOf ───────────────────────────────────────────────────
{"oneOf string branch", `{"oneOf":[{"type":"string"},{"type":"number"}]}`, `"x"`, true, ""},
{"oneOf number branch", `{"oneOf":[{"type":"string"},{"type":"number"}]}`, `7`, true, ""},
{"oneOf no branch", `{"oneOf":[{"type":"string"},{"type":"number"}]}`, `true`, false, `oneOf alternatives`},
// ─── required ────────────────────────────────────────────────
{
"required key present",
`{"type":"object","required":["a"],"properties":{"a":{"type":"string"}}}`,
`{"a":"x"}`, true, "",
},
{
"required key missing",
`{"type":"object","required":["a"]}`,
`{}`, false, `required property "a"`,
},
// ─── nested properties recurse ───────────────────────────────
{
"nested property wrong type",
`{"type":"object","properties":{"inner":{"type":"object","properties":{"x":{"type":"number"}}}}}`,
`{"inner":{"x":"oops"}}`, false, `inner.x: expected type "number"`,
},
// ─── array items recurse with [i] path ───────────────────────
{
"array items ok",
`{"type":"array","items":{"type":"string"}}`,
`["a","b"]`, true, "",
},
{
"array item wrong type pinpoints index",
`{"type":"array","items":{"type":"string"}}`,
`["a",2,"c"]`, false, `[1]: expected type "string"`,
},
// ─── numeric bounds (P0 additions) ───────────────────────────
{"minimum ok", `{"type":"number","minimum":0}`, `0`, true, ""},
{"minimum fail", `{"type":"number","minimum":0}`, `-1`, false, `below minimum`},
{"maximum ok", `{"type":"number","maximum":100}`, `100`, true, ""},
{"maximum fail", `{"type":"number","maximum":100}`, `101`, false, `above maximum`},
{"minimum on integer", `{"type":"integer","minimum":10}`, `5`, false, `below minimum`},
// ─── array length bounds (P0 additions) ──────────────────────
{"minItems ok", `{"type":"array","minItems":1}`, `[1]`, true, ""},
{"minItems fail", `{"type":"array","minItems":1}`, `[]`, false, `array has 0 items, minimum is 1`},
{"maxItems ok", `{"type":"array","maxItems":3}`, `[1,2,3]`, true, ""},
{"maxItems fail", `{"type":"array","maxItems":3}`, `[1,2,3,4]`, false, `array has 4 items, maximum is 3`},
// ─── combined bounds inside nested array of objects ──────────
{
"nested minimum in array item objects",
`{"type":"array","items":{"type":"object","properties":{"row":{"type":"integer","minimum":0}}}}`,
`[{"row":0},{"row":-1}]`, false, `[1].row: value -1 is below minimum 0`,
},
// ─── additionalProperties absent: lenient (default) ──────────
{
"extras allowed when additionalProperties absent",
`{"type":"object","properties":{"a":{"type":"string"}}}`,
`{"a":"x","whatever":42}`, true, "",
},
// ─── additionalProperties:false: strict mode ─────────────────
{
"extras allowed when additionalProperties:true (explicit)",
`{"type":"object","properties":{"a":{"type":"string"}},"additionalProperties":true}`,
`{"a":"x","extra":1}`, true, "",
},
{
"extras rejected when additionalProperties:false",
`{"type":"object","properties":{"a":{"type":"string"}},"additionalProperties":false}`,
`{"a":"x","typo":1}`, false, `unexpected property "typo"`,
},
{
"declared property still accepted under strict mode",
`{"type":"object","properties":{"a":{"type":"string"}},"additionalProperties":false}`,
`{"a":"x"}`, true, "",
},
// ─── additionalProperties:<schema>: extras must match ────────
{
"extras pass when matching additionalProperties schema",
`{"type":"object","properties":{"name":{"type":"string"}},"additionalProperties":{"type":"array","items":{"type":"string"}}}`,
`{"name":"x","g1":["a","b"],"g2":["c"]}`, true, "",
},
{
"extras fail when wrong type for additionalProperties schema",
`{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}}}`,
`{"g1":[1,2]}`, false, `g1[0]: expected type "string"`,
},
{
"extras fail when value isn't even right kind",
`{"type":"object","additionalProperties":{"type":"array"}}`,
`{"key":"not-an-array"}`, false, `key: expected type "array"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := parseSchema(t, tc.schema)
v := parseValue(t, tc.value)
err := validateAgainstSchema(v, s, "")
if tc.wantOK {
if err != nil {
t.Fatalf("expected pass, got error: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got pass", tc.wantInErr)
}
if !strings.Contains(err.Error(), tc.wantInErr) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantInErr)
}
})
}
}
// TestValidateAgainstSchema_EnumErrorEnhancements pins the three
// enum-error UX upgrades together:
// - the failing value is quoted in JSON form ("SUM", not bare SUM)
// - the allowed list is JSON-quoted ("sum", not bare sum) and gets
// truncated past 8 entries with an "N more" hint
// - case-only mismatches surface a `did you mean` suggestion
// pointing at the canonical spelling
func TestValidateAgainstSchema_EnumErrorEnhancements(t *testing.T) {
t.Parallel()
t.Run("small enum is fully listed and quoted", func(t *testing.T) {
t.Parallel()
s := parseSchema(t, `{"type":"string","enum":["asc","desc"]}`)
err := validateAgainstSchema("sideways", s, "order")
if err == nil {
t.Fatal("expected enum violation")
}
msg := err.Error()
if !strings.Contains(msg, `value "sideways"`) {
t.Errorf("want failing value quoted; got %q", msg)
}
if !strings.Contains(msg, `["asc", "desc"]`) {
t.Errorf("want enum list comma+quote formatted; got %q", msg)
}
})
t.Run("large enum is truncated with overflow hint", func(t *testing.T) {
t.Parallel()
// 12 values; default enumDisplayLimit is 8.
s := parseSchema(t, `{"type":"string","enum":[
"a","b","c","d","e","f","g","h","i","j","k","l"
]}`)
err := validateAgainstSchema("z", s, "x")
if err == nil {
t.Fatal("expected enum violation")
}
msg := err.Error()
if !strings.Contains(msg, "4 more") {
t.Errorf("want overflow hint '4 more'; got %q", msg)
}
if strings.Contains(msg, `"i"`) || strings.Contains(msg, `"l"`) {
t.Errorf("want truncation to first 8; got %q", msg)
}
if !strings.Contains(msg, `"h"`) { // 8th entry should be present.
t.Errorf("want first 8 entries shown; got %q", msg)
}
})
t.Run("case-only mismatch produces did-you-mean hint", func(t *testing.T) {
t.Parallel()
s := parseSchema(t, `{"type":"string","enum":["sum","count","average"]}`)
err := validateAgainstSchema("SUM", s, "")
if err == nil {
t.Fatal("expected enum violation")
}
if !strings.Contains(err.Error(), `did you mean "sum"?`) {
t.Errorf("want did-you-mean hint; got %q", err.Error())
}
})
t.Run("no did-you-mean when value is not a near miss", func(t *testing.T) {
t.Parallel()
s := parseSchema(t, `{"type":"string","enum":["sum","count"]}`)
err := validateAgainstSchema("BOGUS", s, "")
if err == nil {
t.Fatal("expected enum violation")
}
if strings.Contains(err.Error(), "did you mean") {
t.Errorf("want no hint for unrelated value; got %q", err.Error())
}
})
t.Run("did-you-mean only triggers for strings (not numbers)", func(t *testing.T) {
t.Parallel()
s := parseSchema(t, `{"enum":[1,2,3]}`)
err := validateAgainstSchema(float64(4), s, "")
if err == nil {
t.Fatal("expected enum violation")
}
if strings.Contains(err.Error(), "did you mean") {
t.Errorf("numeric enum should not get casing hint; got %q", err.Error())
}
// And the failing numeric value still surfaces in JSON form.
if !strings.Contains(err.Error(), "value 4 ") {
t.Errorf("want numeric value in error; got %q", err.Error())
}
})
}
// TestValidateInputAgainstSchema_RealEnumCaseNormalized confirms the
// case-insensitive enum tolerance fires against the real embedded schema for
// the most common real-world miscue — pivot summarize_by upper-cased. "SUM" is
// rewritten to "sum" in place and the input passes; previously this surfaced a
// did-you-mean error, but in-place canonicalization fixes it so the agent's first try wins.
func TestValidateInputAgainstSchema_RealEnumCaseNormalized(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+pivot-create"}
in := map[string]interface{}{
"properties": map[string]interface{}{
"values": []interface{}{
map[string]interface{}{"field": "A", "summarize_by": "SUM"},
},
},
}
if err := validateInputAgainstSchema(fv, in); err != nil {
t.Fatalf("upper-case summarize_by should be normalized and pass, got: %v", err)
}
vals := in["properties"].(map[string]interface{})["values"].([]interface{})
if got := vals[0].(map[string]interface{})["summarize_by"]; got != "sum" {
t.Errorf("summarize_by = %q, want normalized to %q", got, "sum")
}
}
// TestValidateAgainstSchema_NilSchemaSafe pins the defensive
// `if schema == nil { return nil }` guard. Current production callers
// always hand validator a real schema, but the guard means future
// programmatic construction (or a malformed schema sub-tree decoded
// as a nil pointer inside oneOf) won't crash with a nil deref.
func TestValidateAgainstSchema_NilSchemaSafe(t *testing.T) {
t.Parallel()
if err := validateAgainstSchema("anything", nil, ""); err != nil {
t.Errorf("nil schema should noop; got %v", err)
}
}
// TestValidateAgainstSchema_AdditionalPropertiesSortedFirstFailure
// asserts that when multiple extras violate additionalProperties:false,
// the *alphabetically first* extra is the one reported — without the
// sort, Go map iteration would make the failing key non-deterministic
// across runs and the error message would flake.
func TestValidateAgainstSchema_AdditionalPropertiesSortedFirstFailure(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"object",
"properties":{"declared":{"type":"string"}},
"additionalProperties":false
}`)
// Three extras; "alpha" comes first when sorted.
value := parseValue(t, `{"declared":"ok","zeta":1,"alpha":2,"middle":3}`)
for i := 0; i < 30; i++ {
err := validateAgainstSchema(value, schema, "")
if err == nil {
t.Fatalf("iter %d: expected extras to be rejected", i)
}
if !strings.Contains(err.Error(), `"alpha"`) {
t.Fatalf("iter %d: expected alphabetically first extra to be reported; got %v", i, err)
}
}
}
// TestValidateAgainstSchema_ArrayItemRequired pins that `required`
// fires inside array items too — the recursion path applies the same
// object-level rules at every level, so a missing key in items
// surfaces as `[i].missing` and not a silently-passed item.
func TestValidateAgainstSchema_ArrayItemRequired(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"array",
"items":{
"type":"object",
"required":["id"],
"properties":{"id":{"type":"string"}}
}
}`)
value := parseValue(t, `[{"id":"a"},{"name":"b"}]`)
err := validateAgainstSchema(value, schema, "")
if err == nil {
t.Fatal("expected required violation on items[1]")
}
if !strings.Contains(err.Error(), `required property "id"`) || !strings.Contains(err.Error(), "[1]") {
t.Errorf("expected required-id at [1]; got %v", err)
}
}
// TestValidateAgainstSchema_DeterministicPropertyOrder regresses the
// "iterate properties in sorted key order" guarantee so that the
// first-failure error message is stable across runs (Go map iteration
// is randomized — without the sort, a schema with two bad fields
// would non-deterministically report either one).
func TestValidateAgainstSchema_DeterministicPropertyOrder(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"object",
"properties":{
"a":{"type":"string"},
"b":{"type":"string"},
"c":{"type":"string"}
}
}`)
value := parseValue(t, `{"a":1,"b":2,"c":3}`)
// Run many times; "a" must always be the reported field (sorted first).
for i := 0; i < 50; i++ {
err := validateAgainstSchema(value, schema, "")
if err == nil || !strings.Contains(err.Error(), "a:") {
t.Fatalf("iter %d: expected error mentioning 'a:'; got %v", i, err)
}
}
}
// TestValidateInputAgainstSchema_RealSchema exercises the full
// (command, flag) lookup pipeline against the real embedded
// flag-schemas.json — confirms that an out-of-enum summarize_by
// surfaces a descriptive error all the way through, and that a
// well-formed input passes. Mirrors what shortcut tests check, but
// without booting cobra.
func TestValidateInputAgainstSchema_RealSchema(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+pivot-create"}
// Schema-conformant: values[0].summarize_by="sum" is in enum.
good := map[string]interface{}{
"properties": map[string]interface{}{
"values": []interface{}{
map[string]interface{}{"field": "A", "summarize_by": "sum"},
},
},
}
if err := validateInputAgainstSchema(fv, good); err != nil {
t.Errorf("good input rejected: %v", err)
}
// Schema-violating: a value with no case-only match still fails loudly
// (case normalization only rescues casing mistakes, not unknown words).
bad := map[string]interface{}{
"properties": map[string]interface{}{
"values": []interface{}{
map[string]interface{}{"field": "A", "summarize_by": "bogus"},
},
},
}
err := validateInputAgainstSchema(fv, bad)
if err == nil {
t.Fatal("expected enum violation, got nil")
}
if !strings.Contains(err.Error(), "summarize_by") || !strings.Contains(err.Error(), "not in enum") {
t.Errorf("error = %q, want summarize_by + enum hint", err.Error())
}
}
// TestValidateInputAgainstSchema_RealMinItems exercises a P0
// addition end-to-end: +pivot-create properties.values has
// minItems:1, so an explicit empty values array is rejected by the
// schema validator (previously slipped past).
func TestValidateInputAgainstSchema_RealMinItems(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+pivot-create"}
bad := map[string]interface{}{
"properties": map[string]interface{}{
"values": []interface{}{}, // minItems:1 violated
},
}
err := validateInputAgainstSchema(fv, bad)
if err == nil {
t.Fatal("expected minItems violation for empty values, got nil")
}
if !strings.Contains(err.Error(), "values") || !strings.Contains(err.Error(), "minimum is 1") {
t.Errorf("error = %q, want values + minimum-is-1 hint", err.Error())
}
}
// TestValidateInputAgainstSchema_RealMinimum exercises another P0
// addition: +chart-create properties.position.row has minimum:0, so
// row:-1 must be rejected before the request hits the wire.
func TestValidateInputAgainstSchema_RealMinimum(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+chart-create"}
bad := map[string]interface{}{
"properties": map[string]interface{}{
"position": map[string]interface{}{"row": float64(-1), "col": "A"},
"size": map[string]interface{}{"width": float64(400), "height": float64(300)},
},
}
err := validateInputAgainstSchema(fv, bad)
if err == nil {
t.Fatal("expected minimum violation for row:-1, got nil")
}
if !strings.Contains(err.Error(), "row") || !strings.Contains(err.Error(), "below minimum") {
t.Errorf("error = %q, want row + below-minimum hint", err.Error())
}
}
// TestValidateInputAgainstSchema_RealAdditionalProperties pins the
// additionalProperties: <schema> form against the real embedded
// schema. +pivot-create properties.collapse is declared as a dynamic
// map<field-name, array<string>>; passing a non-string in any value
// must be rejected end-to-end.
func TestValidateInputAgainstSchema_RealAdditionalProperties(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+pivot-create"}
good := map[string]interface{}{
"properties": map[string]interface{}{
"values": []interface{}{map[string]interface{}{"field": "A", "summarize_by": "sum"}},
"collapse": map[string]interface{}{"region": []interface{}{"NA", "EU"}},
},
}
if err := validateInputAgainstSchema(fv, good); err != nil {
t.Errorf("schema-conformant collapse rejected: %v", err)
}
bad := map[string]interface{}{
"properties": map[string]interface{}{
"values": []interface{}{map[string]interface{}{"field": "A", "summarize_by": "sum"}},
"collapse": map[string]interface{}{"region": []interface{}{"NA", 42}}, // 42 violates items.type=string
},
}
err := validateInputAgainstSchema(fv, bad)
if err == nil {
t.Fatal("expected additionalProperties violation, got nil")
}
if !strings.Contains(err.Error(), "collapse") || !strings.Contains(err.Error(), `expected type "string"`) {
t.Errorf("error = %q, want collapse + string-type hint", err.Error())
}
}
// TestValidateInputAgainstSchema_UnknownCommand returns nil — schema
// validation is opportunistic, an unknown command never errors. Lets
// shortcuts opt out simply by not registering a schema entry.
func TestValidateInputAgainstSchema_UnknownCommand(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+definitely-not-a-shortcut"}
if err := validateInputAgainstSchema(fv, map[string]interface{}{"properties": "anything"}); err != nil {
t.Errorf("unknown command should noop; got %v", err)
}
}
// TestValidateInputAgainstSchema_SkipOperations confirms that the
// operations skip-list entry is honoured: even with a clearly
// malformed operations value, validateInputAgainstSchema is a no-op
// because translator-side validation owns that contract.
func TestValidateInputAgainstSchema_SkipOperations(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+batch-update"}
input := map[string]interface{}{
"operations": "definitely-not-an-array",
}
if err := validateInputAgainstSchema(fv, input); err != nil {
t.Errorf("operations should be skipped; got %v", err)
}
}

View File

@@ -1,37 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Code generated from data/flag-schemas.json; DO NOT EDIT.
package sheets
// commandsWithSchema is the set of shortcut commands that have at least one
// introspectable composite flag in data/flag-schemas.json. Codegen'd so the
// registration loop (shortcuts.go) and the validate fast-path can gate on it
// without parsing the 256KB schema blob at startup (that parse used to run on
// every CLI invocation, sheets or not). The 256KB is now only unmarshaled
// on --print-schema or when validating a command that is in this set. Do not
// hand-edit; regenerate with `go generate ./shortcuts/sheets/...`.
var commandsWithSchema = map[string]struct{}{
"+batch-update": {},
"+cells-batch-set-style": {},
"+cells-set": {},
"+cells-set-style": {},
"+chart-create": {},
"+chart-update": {},
"+cond-format-create": {},
"+cond-format-update": {},
"+dropdown-set": {},
"+dropdown-update": {},
"+filter-create": {},
"+filter-update": {},
"+filter-view-create": {},
"+filter-view-update": {},
"+pivot-create": {},
"+pivot-update": {},
"+range-sort": {},
"+sparkline-create": {},
"+sparkline-update": {},
"+table-put": {},
"+workbook-create": {},
}

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"reflect"
"testing"
)
// TestCommandsWithSchemaGen_MatchesJSON guards against drift between the
// codegen'd commandsWithSchema set (flag_schemas_gen.go) and the actual keys
// in data/flag-schemas.json — commandsWithFlagSchema() derives the set by
// parsing the embedded blob. This equivalence is what lets registration and
// the validate fast-path gate on the cheap set instead of parsing the 256KB
// schema at startup.
func TestCommandsWithSchemaGen_MatchesJSON(t *testing.T) {
t.Parallel()
fromJSON := commandsWithFlagSchema()
if !reflect.DeepEqual(fromJSON, commandsWithSchema) {
t.Error("commandsWithSchema differs from data/flag-schemas.json; regenerate flag_schemas_gen.go")
}
}

View File

@@ -1,321 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
)
// flagView is the read-only flag-accessor surface that every CLI-shape →
// MCP-tool-body translator (the *Input builders) depends on. It is satisfied
// as-is by *common.RuntimeContext (cobra-backed, used by standalone shortcut
// execution) and by mapFlagView (map-backed, used by +batch-update sub-ops).
//
// Routing both paths through the same interface lets a sub-op inside
// +batch-update reuse the exact same translator the standalone shortcut runs,
// so the generated MCP body is identical either way (enforced by the
// batch-vs-standalone contract test).
type flagView interface {
Str(name string) string
Int(name string) int
Float64(name string) float64
Bool(name string) bool
StrArray(name string) []string
StrSlice(name string) []string
Changed(name string) bool
// Command returns the shortcut command this view feeds (e.g.
// "+pivot-create"). Used to look up the schema entry for
// schema-driven flag validation; both standalone and batch sub-op
// paths populate it so a sub-op gets validated against the same
// schema as the standalone shortcut.
Command() string
}
// mapFlagView adapts a +batch-update sub-op input object (decoded JSON) to the
// flagView interface so the standalone *Input translators can consume it.
//
// Keys are matched leniently against the CLI flag name: a translator asking for
// "source-range" finds either "source-range" or "source_range" in the map (the
// reference docs use CLI flag names; users frequently send the underscore
// form). Composite values (arrays / objects for flags like cells / properties /
// sort-keys) are re-encoded to a JSON string on Str() so the downstream
// parseJSONFlag round-trips them exactly as it would a CLI string argument.
//
// To mirror the standalone cobra layer exactly, value reads fall back to the
// flag's declared default (seeded from flag-defs.json), while Changed() reflects
// only what the user actually provided. This split matters because some
// translators branch on Changed() (e.g. omit target_index unless --index was
// set) and others read defaulted values (e.g. row-count defaults to 200).
type mapFlagView struct {
raw map[string]interface{} // user-supplied sub-op input (drives Changed)
defaults map[string]interface{} // flag defaults (value fallback only)
command string // shortcut command (e.g. "+chart-create"); used by schema validator
}
func (m mapFlagView) Command() string { return m.command }
// newMapFlagViewForCommand wraps a sub-op input and seeds the value-fallback
// defaults declared for `command` in flag-defs.json, so an absent flag resolves
// to the same value the standalone cobra command would carry.
func newMapFlagViewForCommand(command string, input map[string]interface{}) mapFlagView {
fv := mapFlagView{raw: input, defaults: map[string]interface{}{}, command: command}
defs, err := loadFlagDefs()
if err != nil {
return fv
}
spec, ok := defs[command]
if !ok {
return fv
}
for _, df := range spec.Flags {
if df.Kind == "system" || df.Default == "" {
continue
}
fv.defaults[df.Name] = typedDefault(df)
}
return fv
}
// typedDefault converts a flag's string default to the Go type matching its
// declared kind, so Int()/Bool()/Float64() see the right type.
func typedDefault(df flagDef) interface{} {
switch df.Type {
case "bool":
return df.Default == "true"
case "int":
var n int
fmt.Sscanf(df.Default, "%d", &n)
return n
case "float64":
var f float64
fmt.Sscanf(df.Default, "%g", &f)
return f
default:
return df.Default
}
}
// lookup resolves a flag name for a VALUE read: user input first (hyphen↔
// underscore tolerant), then the seeded default. Returns the value and whether
// it was found in either source.
func (m mapFlagView) lookup(name string) (interface{}, bool) {
if v, ok := m.lookupRaw(name); ok {
return v, true
}
if m.defaults != nil {
if v, ok := m.defaults[name]; ok {
return v, true
}
}
return nil, false
}
// lookupRaw resolves a flag name against the user-supplied input only, trying
// the exact key then the hyphen↔underscore variants.
func (m mapFlagView) lookupRaw(name string) (interface{}, bool) {
if v, ok := m.raw[name]; ok {
return v, true
}
if alt := strings.ReplaceAll(name, "-", "_"); alt != name {
if v, ok := m.raw[alt]; ok {
return v, true
}
}
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
if v, ok := m.raw[alt]; ok {
return v, true
}
}
return nil, false
}
func (m mapFlagView) Str(name string) string {
v, ok := m.lookup(name)
if !ok || v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
case bool, float64, int, int64:
b, _ := json.Marshal(t)
return string(b)
default:
// Arrays / objects (cells, properties, sort-keys, options, ...) are
// re-encoded so the translator's parseJSONFlag re-parses them.
b, err := json.Marshal(t)
if err != nil {
return ""
}
return string(b)
}
}
func (m mapFlagView) Int(name string) int {
v, ok := m.lookup(name)
if !ok {
return 0
}
switch t := v.(type) {
case float64:
return int(t)
case int:
return t
case int64:
return int(t)
}
return 0
}
func (m mapFlagView) Float64(name string) float64 {
v, ok := m.lookup(name)
if !ok {
return 0
}
switch t := v.(type) {
case float64:
return t
case int:
return float64(t)
case int64:
return float64(t)
}
return 0
}
func (m mapFlagView) Bool(name string) bool {
v, ok := m.lookup(name)
if !ok {
return false
}
b, _ := v.(bool)
return b
}
func (m mapFlagView) StrArray(name string) []string {
return m.strSliceLike(name)
}
func (m mapFlagView) StrSlice(name string) []string {
return m.strSliceLike(name)
}
func (m mapFlagView) strSliceLike(name string) []string {
v, ok := m.lookup(name)
if !ok || v == nil {
return nil
}
switch t := v.(type) {
case []string:
return t
case []interface{}:
out := make([]string, 0, len(t))
for _, e := range t {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
case string:
// CSV / comma-separated (matches cobra StringSlice behavior).
if t == "" {
return nil
}
return strings.Split(t, ",")
}
return nil
}
func (m mapFlagView) Changed(name string) bool {
_, ok := m.lookupRaw(name)
return ok
}
// validateRawTypes rejects sub-op input fields whose JSON type contradicts the
// flag's declared type in flag-defs. +batch-update skips parse-time schema
// validation for `operations`, and Int/Float64/Bool silently fall back to
// the zero value on a type mismatch — so without this guard a wrong-typed scalar
// (e.g. "index":"abc" or "multiple":"true") would land as 0 / false instead of
// erroring, writing to the wrong place. Only numeric and boolean flags are
// checked; string and composite (array/object) flags stay permissive because
// Str() intentionally coerces them and the translator/schema validates shape.
//
// Returns a bare error; the +batch-update translator wraps it with the
// operations[i] (<shortcut>) context.
func (m mapFlagView) validateRawTypes() error {
if len(m.raw) == 0 {
return nil
}
defs, err := loadFlagDefs()
if err != nil {
return nil //nolint:nilerr // fail-open: if flag-defs can't load, skip type validation rather than block the batch
}
spec, ok := defs[m.command]
if !ok {
return nil
}
declaredType := make(map[string]string, len(spec.Flags))
for _, df := range spec.Flags {
declaredType[df.Name] = df.Type
}
for rawKey, val := range m.raw {
name := rawKey
typ, ok := declaredType[name]
if !ok {
// flag-defs use hyphen names; tolerate the underscore form users send.
name = strings.ReplaceAll(rawKey, "_", "-")
typ, ok = declaredType[name]
}
if !ok {
continue // unknown key — leave it for the translator / schema layer
}
switch typ {
case "int":
// Int(): float64 → int(t) truncates, so a non-integer number would
// be silently floored (1.9 → 1). Standalone cobra rejects it at
// parse time; reject here too to keep batch/standalone parity.
f, isNum := val.(float64)
if !isNum {
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
}
if math.Trunc(f) != f {
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64))
}
case "float64":
if _, isNum := val.(float64); !isNum {
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
}
case "bool":
if _, isBool := val.(bool); !isBool {
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val))
}
}
}
return nil
}
// jsonTypeName names the JSON kind of a value decoded by encoding/json, for
// type-mismatch error messages.
func jsonTypeName(v interface{}) string {
switch v.(type) {
case nil:
return "null"
case bool:
return "boolean"
case float64:
return "number"
case string:
return "string"
case []interface{}:
return "array"
case map[string]interface{}:
return "object"
default:
return fmt.Sprintf("%T", v)
}
}

View File

@@ -1,13 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
// flag_defs_gen.go and flag_schemas_gen.go are generated from the canonical
// data/*.json spec artifacts (synced from sheet-skill-spec). After the sync
// script updates data/flag-defs.json or data/flag-schemas.json, regenerate
// the compiled Go with:
//
// go generate ./shortcuts/sheets/...
//
//go:generate go run ./internal/gen

View File

@@ -1,52 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package sheets contains lark-sheets shortcuts aligned with the
// sheet-skill-spec canonical layout. Each shortcut wraps a single
// sheet-ai-skills tool behind the One-OpenAPI endpoint
// (sheet_ai/v2/.../tools/invoke_{read,write}).
package sheets
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR
// pair shared by every sheets canonical shortcut and returns the resolved
// token. Network-free, safe to call from Validate and DryRun.
func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil {
var (
singleCellRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*$`)
cellSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+[1-9][0-9]*$`)
cellToColRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+$`)
colSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+:[A-Za-z]+$`)
rowSpanRangePattern = regexp.MustCompile(`^[1-9][0-9]*:[1-9][0-9]*$`)
cellRefPattern = regexp.MustCompile(`^([A-Za-z]+)([1-9][0-9]*)$`)
)
var sheetRangeSeparatorReplacer = strings.NewReplacer(`\`, "!", `\!`, "!", "", "!")
// getFirstSheetID queries the spreadsheet and returns the first sheet's ID.
func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) {
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
if err != nil {
return "", err
}
if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" {
if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil {
return "", common.FlagErrorf("%v", err)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) > 0 {
sheet, _ := sheets[0].(map[string]interface{})
if id, ok := sheet["sheet_id"].(string); ok && id != "" {
return id, nil
}
return token, nil
}
url := strings.TrimSpace(runtime.Str("url"))
token := extractSpreadsheetToken(url)
if token == "" || token == url {
return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/<token>")
}
if err := validate.RejectControlChars(token, "url"); err != nil {
return "", common.FlagErrorf("%v", err)
}
return token, nil
return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet")
}
// extractSpreadsheetToken pulls the token segment out of a /sheets/<token>
// or /spreadsheets/<token> URL. Returns the input unchanged when no known
// prefix is present (callers must check token != originalInput).
// extractSpreadsheetToken extracts spreadsheet token from URL.
func extractSpreadsheetToken(input string) string {
input = strings.TrimSpace(input)
for _, prefix := range []string{"/sheets/", "/spreadsheets/"} {
prefixes := []string{"/sheets/", "/spreadsheets/"}
for _, prefix := range prefixes {
if idx := strings.Index(input, prefix); idx >= 0 {
token := input[idx+len(prefix):]
if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 {
@@ -58,254 +57,183 @@ func extractSpreadsheetToken(input string) string {
return input
}
// resolveSheetSelector validates the --sheet-id / --sheet-name XOR and
// returns whichever was supplied. Network-free.
//
// Returned tuple: (sheetID, sheetName). Exactly one is non-empty — callers
// pass both through to the tool input; the server picks whichever fits.
func resolveSheetSelector(runtime *common.RuntimeContext) (sheetID, sheetName string, err error) {
if err := common.ExactlyOne(runtime, "sheet-id", "sheet-name"); err != nil {
return "", "", err
func normalizeSheetRange(sheetID, input string) string {
input = normalizeSheetRangeSeparators(input)
if input == "" || strings.Contains(input, "!") || sheetID == "" {
return input
}
if id := strings.TrimSpace(runtime.Str("sheet-id")); id != "" {
if err := validate.RejectControlChars(id, "sheet-id"); err != nil {
return "", "", common.FlagErrorf("%v", err)
}
return id, "", nil
if looksLikeRelativeRange(input) {
return sheetID + "!" + input
}
name := strings.TrimSpace(runtime.Str("sheet-name"))
if err := validate.RejectControlChars(name, "sheet-name"); err != nil {
return "", "", common.FlagErrorf("%v", err)
}
return "", name, nil
return input
}
// validateViaInput shrinks a shortcut's Validate to the minimal
// "token + ask the xxxInput builder if everything else is OK" pattern.
// The builder owns the sheet selector and shortcut-specific checks
// (--range required, --start >= 0, ...), so Validate no longer duplicates
// them — the same error fires whether the shortcut runs standalone or as a
// +batch-update sub-op. Use the inline form when the builder needs extra
// arguments (operation enum, withMergeType bool, ...).
func validateViaInput(
build func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error),
) func(ctx context.Context, runtime *common.RuntimeContext) error {
return func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID := strings.TrimSpace(runtime.Str("sheet-id"))
sheetName := strings.TrimSpace(runtime.Str("sheet-name"))
_, err = build(runtime, token, sheetID, sheetName)
return err
func normalizePointRange(sheetID, input string) string {
input = normalizeSheetRange(sheetID, input)
if input == "" {
return input
}
rangeSheetID, subRange, ok := splitSheetRange(input)
if !ok || !singleCellRangePattern.MatchString(subRange) {
return input
}
return rangeSheetID + "!" + subRange + ":" + subRange
}
// requireSheetSelector is the flagView-agnostic counterpart of
// resolveSheetSelector: given the already-extracted (sheetID, sheetName) pair,
// it enforces the same XOR and control-char rules.
//
// Every batchable xxxInput builder calls this at the top so the same friendly
// error fires whether the shortcut runs standalone (Validate sees the error
// through the builder) or as a +batch-update sub-op (translator sees it
// directly, prefixed by operations[i]). Without this, batch sub-ops
// missing --sheet-id would slip through CLI validation and only fail on the
// server with an opaque "sheet undefined not found".
func requireSheetSelector(sheetID, sheetName string) error {
sheetID = strings.TrimSpace(sheetID)
sheetName = strings.TrimSpace(sheetName)
if sheetID == "" && sheetName == "" {
return common.FlagErrorf("specify at least one of --sheet-id or --sheet-name")
func normalizeWriteRange(sheetID, input string, values interface{}) string {
rows, cols := matrixDimensions(values)
input = normalizeSheetRangeSeparators(input)
if input == "" {
return buildRectRange(sheetID, "A1", rows, cols)
}
if sheetID != "" && sheetName != "" {
return common.FlagErrorf("--sheet-id and --sheet-name are mutually exclusive")
input = normalizeSheetRange(sheetID, input)
rangeSheetID, subRange, ok := splitSheetRange(input)
if !ok {
return buildRectRange(input, "A1", rows, cols)
}
if sheetID != "" {
if err := validate.RejectControlChars(sheetID, "sheet-id"); err != nil {
return common.FlagErrorf("%v", err)
}
} else {
if err := validate.RejectControlChars(sheetName, "sheet-name"); err != nil {
return common.FlagErrorf("%v", err)
}
if singleCellRangePattern.MatchString(subRange) {
return buildRectRange(rangeSheetID, subRange, rows, cols)
}
return input
}
func validateSheetRangeInput(sheetID, input string) error {
input = normalizeSheetRangeSeparators(input)
if input == "" || strings.Contains(input, "!") || sheetID != "" {
return nil
}
if looksLikeRelativeRange(input) {
return common.FlagErrorf("--range %q requires --sheet-id or a <sheetId>! prefix", input)
}
return nil
}
// optionalSheetSelector is the "at most one" counterpart of
// requireSheetSelector: both empty is acceptable (the backend tool then
// decides what to do — e.g. manage_pivot_table_object auto-creates a new
// sub-sheet to host the pivot), and both set is rejected. Control-char
// validation still applies whenever a value is provided.
//
// Used by shortcuts whose backend tool treats sheet_id/sheet_name as the
// placement target rather than the operation context (currently only
// +pivot-create). Other shortcuts continue to use requireSheetSelector.
//
// idFlagName / nameFlagName parameterize the flag names quoted back in
// the mutex / control-char errors — +pivot-create exposes the placement
// selector as `--target-sheet-id` / `--target-sheet-name`, not the
// generic `--sheet-id` / `--sheet-name`, and the error wording must
// match what the user actually typed.
func optionalSheetSelector(sheetID, sheetName, idFlagName, nameFlagName string) error {
sheetID = strings.TrimSpace(sheetID)
sheetName = strings.TrimSpace(sheetName)
if sheetID != "" && sheetName != "" {
return common.FlagErrorf("--%s and --%s are mutually exclusive", idFlagName, nameFlagName)
// validateSingleCellRange rejects multi-cell spans (e.g. "A1:B2") that are
// invalid for single-cell operations like write-image. Empty and single-cell
// values pass through.
func validateSingleCellRange(input string) error {
input = normalizeSheetRangeSeparators(input)
if input == "" {
return nil
}
if sheetID != "" {
if err := validate.RejectControlChars(sheetID, idFlagName); err != nil {
return common.FlagErrorf("%v", err)
}
} else if sheetName != "" {
if err := validate.RejectControlChars(sheetName, nameFlagName); err != nil {
return common.FlagErrorf("%v", err)
// Extract the sub-range after the sheet ID prefix, if present.
subRange := input
if _, sr, ok := splitSheetRange(input); ok {
subRange = sr
}
if cellSpanRangePattern.MatchString(subRange) {
parts := strings.SplitN(subRange, ":", 2)
if strings.EqualFold(parts[0], parts[1]) {
return nil
}
return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input)
}
return nil
}
// sheetSelectorForToolInput packs --sheet-id / --sheet-name into the tool
// input map, omitting empty fields. Use after resolveSheetSelector returns.
func sheetSelectorForToolInput(input map[string]interface{}, sheetID, sheetName string) {
if sheetID != "" {
input["sheet_id"] = sheetID
}
if sheetName != "" {
input["sheet_name"] = sheetName
func looksLikeRelativeRange(input string) bool {
input = normalizeSheetRangeSeparators(input)
if input == "" {
return false
}
return singleCellRangePattern.MatchString(input) ||
cellSpanRangePattern.MatchString(input) ||
cellToColRangePattern.MatchString(input) ||
colSpanRangePattern.MatchString(input) ||
rowSpanRangePattern.MatchString(input)
}
// sheetSelectorPlaceholder returns a human-readable identifier for the
// selected sheet, suitable for DryRun output. Avoids leaking that --sheet-name
// would be resolved server-side at execute time.
func sheetSelectorPlaceholder(sheetID, sheetName string) string {
if sheetID != "" {
func splitSheetRange(input string) (sheetID, subRange string, ok bool) {
parts := strings.SplitN(normalizeSheetRangeSeparators(input), "!", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", false
}
return parts[0], parts[1], true
}
func normalizeSheetRangeSeparators(input string) string {
input = strings.TrimSpace(input)
if input == "" {
return input
}
return sheetRangeSeparatorReplacer.Replace(input)
}
func buildRectRange(sheetID, anchor string, rows, cols int) string {
if sheetID == "" {
return ""
}
if rows < 1 {
rows = 1
}
if cols < 1 {
cols = 1
}
endCell, err := offsetCell(anchor, rows-1, cols-1)
if err != nil {
return sheetID
}
return "<resolve:" + sheetName + ">"
return sheetID + "!" + anchor + ":" + endCell
}
// parseJSONFlag parses a JSON string from a flag value. Returns nil when the
// flag is empty (caller decides if that's acceptable). Used by --data /
// --style / --options / --ranges / --colors and friends.
func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
raw := strings.TrimSpace(runtime.Str(name))
if raw == "" {
return nil, nil
func matrixDimensions(values interface{}) (rows, cols int) {
rowList, ok := values.([]interface{})
if !ok || len(rowList) == 0 {
return 1, 1
}
var out interface{}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return nil, common.FlagErrorf("--%s: invalid JSON: %v", name, err)
rows = len(rowList)
for _, row := range rowList {
if cells, ok := row.([]interface{}); ok && len(cells) > cols {
cols = len(cells)
}
}
// Schema-driven flag validation at the user-input boundary. Skips
// --properties (validated at the input-builder tail after enhance
// hooks fill in flat-flag-derived fields) and any flag without an
// embedded schema entry.
if err := validateParsedJSONFlag(runtime, name, out); err != nil {
return nil, err
if cols == 0 {
cols = 1
}
return out, nil
return rows, cols
}
// requireJSONObject is parseJSONFlag + a type assertion to map[string]interface{}.
func requireJSONObject(runtime flagView, name string) (map[string]interface{}, error) {
v, err := parseJSONFlag(runtime, name)
func offsetCell(cell string, rowOffset, colOffset int) (string, error) {
matches := cellRefPattern.FindStringSubmatch(strings.TrimSpace(cell))
if len(matches) != 3 {
return "", fmt.Errorf("invalid cell reference: %s", cell)
}
colIndex := columnNameToIndex(matches[1])
if colIndex < 1 {
return "", fmt.Errorf("invalid column: %s", matches[1])
}
rowIndex, err := strconv.Atoi(matches[2])
if err != nil {
return nil, err
return "", err
}
if v == nil {
return nil, common.FlagErrorf("--%s is required", name)
}
m, ok := v.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("--%s must be a JSON object", name)
}
return m, nil
return fmt.Sprintf("%s%d", columnIndexToName(colIndex+colOffset), rowIndex+rowOffset), nil
}
// requireJSONArray is parseJSONFlag + a type assertion to []interface{}.
func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
v, err := parseJSONFlag(runtime, name)
if err != nil {
return nil, err
func columnNameToIndex(name string) int {
name = strings.ToUpper(strings.TrimSpace(name))
if name == "" {
return 0
}
if v == nil {
return nil, common.FlagErrorf("--%s is required", name)
index := 0
for _, r := range name {
if r < 'A' || r > 'Z' {
return 0
}
index = index*26 + int(r-'A'+1)
}
a, ok := v.([]interface{})
if !ok {
return nil, common.FlagErrorf("--%s must be a JSON array", name)
}
return a, nil
return index
}
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
// buildCellStyleFromFlags reads the 11 flat style flags and returns the
// cell_styles map expected by set_cell_range. Skips any flag the user
// didn't set so partial styles work.
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
style := map[string]interface{}{}
if v := runtime.Str("background-color"); v != "" {
style["background_color"] = v
func columnIndexToName(index int) string {
if index < 1 {
return ""
}
if v := runtime.Str("font-color"); v != "" {
style["font_color"] = v
var out []byte
for index > 0 {
index--
out = append([]byte{byte('A' + index%26)}, out...)
index /= 26
}
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
style["font_size"] = runtime.Float64("font-size")
}
if v := runtime.Str("font-style"); v != "" {
style["font_style"] = v
}
if v := runtime.Str("font-weight"); v != "" {
style["font_weight"] = v
}
if v := runtime.Str("font-line"); v != "" {
style["font_line"] = v
}
if v := runtime.Str("horizontal-alignment"); v != "" {
style["horizontal_alignment"] = v
}
if v := runtime.Str("vertical-alignment"); v != "" {
style["vertical_alignment"] = v
}
if v := runtime.Str("word-wrap"); v != "" {
style["word_wrap"] = v
}
if v := runtime.Str("number-format"); v != "" {
style["number_format"] = v
}
return style
}
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
// left/right with style sub-objects). Returns nil when the flag is empty.
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
if runtime.Str("border-styles") == "" {
return nil, nil
}
v, err := parseJSONFlag(runtime, "border-styles")
if err != nil {
return nil, err
}
m, ok := v.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("--border-styles must be a JSON object")
}
return m, nil
}
// requireAnyStyleFlag ensures at least one style-defining flag (style or
// border) is set — otherwise the request would do nothing.
func requireAnyStyleFlag(runtime flagView) error {
if len(buildCellStyleFromFlags(runtime)) > 0 {
return nil
}
if runtime.Str("border-styles") != "" {
return nil
}
return common.FlagErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)")
return string(out)
}

View File

@@ -1,203 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// testConfig returns a CliConfig wired with a stable user identity. Tests
// keep the AppID test-prefixed so logs / metrics can spot them.
func testConfig(t *testing.T) *core.CliConfig {
t.Helper()
replacer := strings.NewReplacer("/", "-", " ", "-")
suffix := replacer.Replace(strings.ToLower(t.Name()))
return &core.CliConfig{
AppID: "test-sheets-" + suffix,
AppSecret: "secret-sheets-" + suffix,
Brand: core.BrandFeishu,
UserOpenId: "ou_test_user",
}
}
// newTestRig spins up a Factory wired with httpmock + the given shortcut
// mounted into a "sheets" parent command. Returns the cobra.Command ready
// to SetArgs / Execute, plus the stdout / stderr buffers and the registry.
func newTestRig(t *testing.T, sc common.Shortcut) (*cobra.Command, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
f, stdout, stderr, reg := cmdutil.TestFactory(t, testConfig(t))
parent := &cobra.Command{Use: "sheets"}
sc.Mount(parent, f)
parent.SilenceErrors = true
parent.SilenceUsage = true
return parent, stdout, stderr, reg
}
// runShortcut executes the shortcut with the given args and returns the
// captured stdout text. Mirrors the legacy package's parent.Execute()
// flow so test cases stay close to real CLI behavior.
func runShortcut(t *testing.T, sc common.Shortcut, args []string) (string, error) {
t.Helper()
parent, stdout, _, _ := newTestRig(t, sc)
parent.SetArgs(append([]string{sc.Command}, args...))
err := parent.Execute()
return stdout.String(), err
}
// runShortcutCapturingErr is runShortcut but also returns the stderr text
// so validation tests can inspect error envelopes.
func runShortcutCapturingErr(t *testing.T, sc common.Shortcut, args []string) (stdoutStr, stderrStr string, err error) {
t.Helper()
parent, stdout, stderr, _ := newTestRig(t, sc)
parent.SetArgs(append([]string{sc.Command}, args...))
err = parent.Execute()
return stdout.String(), stderr.String(), err
}
// runShortcutWithStubs is runShortcut + a slice of httpmock stubs.
// Stubs are registered before execute so the recorded API calls are
// served from the registry instead of touching the network.
func runShortcutWithStubs(t *testing.T, sc common.Shortcut, args []string, stubs ...*httpmock.Stub) (string, error) {
t.Helper()
parent, stdout, _, reg := newTestRig(t, sc)
for _, s := range stubs {
reg.Register(s)
}
parent.SetArgs(append([]string{sc.Command}, args...))
err := parent.Execute()
return stdout.String(), err
}
// parseDryRunBody runs the shortcut in --dry-run and returns the first
// api call's body. The dry-run output format is:
//
// === Dry Run ===
// { "api": [{...}], ... }
//
// Tests use this to assert the One-OpenAPI wire body is constructed
// correctly without exercising the real endpoint.
func parseDryRunBody(t *testing.T, sc common.Shortcut, args []string) map[string]interface{} {
t.Helper()
out, err := runShortcut(t, sc, append(args, "--dry-run"))
if err != nil {
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
}
return decodeDryRunFirstCall(t, out)
}
// parseDryRunAPI returns the full list of `api` entries from a dry-run
// output — used by shortcuts that emit multiple calls (e.g.
// +workbook-export, +cells-set-image, +cells-batch-set-style).
func parseDryRunAPI(t *testing.T, sc common.Shortcut, args []string) []interface{} {
t.Helper()
out, err := runShortcut(t, sc, append(args, "--dry-run"))
if err != nil {
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
}
dryRun := decodeDryRunRaw(t, out)
calls, _ := dryRun["api"].([]interface{})
return calls
}
func decodeDryRunRaw(t *testing.T, out string) map[string]interface{} {
t.Helper()
idx := strings.Index(out, "{")
if idx < 0 {
t.Fatalf("dry-run output has no JSON body:\n%s", out)
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(out[idx:]), &m); err != nil {
t.Fatalf("failed to parse dry-run JSON: %v\nraw=%s", err, out)
}
return m
}
func decodeDryRunFirstCall(t *testing.T, out string) map[string]interface{} {
t.Helper()
dryRun := decodeDryRunRaw(t, out)
calls, ok := dryRun["api"].([]interface{})
if !ok || len(calls) == 0 {
t.Fatalf("dry-run api array empty or wrong shape: %#v", dryRun)
}
call, _ := calls[0].(map[string]interface{})
body, _ := call["body"].(map[string]interface{})
if body == nil {
t.Fatalf("dry-run first call has no body: %#v", call)
}
return body
}
// decodeToolInput parses the JSON-string `input` field embedded in a
// dry-run body whose tool_name matches `expected`. Returns the decoded
// tool input map so tests can assert on specific input fields.
func decodeToolInput(t *testing.T, body map[string]interface{}, expectedToolName string) map[string]interface{} {
t.Helper()
if got, _ := body["tool_name"].(string); got != expectedToolName {
t.Fatalf("tool_name = %q, want %q", got, expectedToolName)
}
rawInput, _ := body["input"].(string)
if rawInput == "" {
t.Fatalf("body.input is empty: %#v", body)
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(rawInput), &input); err != nil {
t.Fatalf("failed to parse tool input JSON: %v\nraw=%s", err, rawInput)
}
return input
}
// decodeEnvelopeData parses a successful envelope's data field — used by
// execute-path tests that go through the full callTool stack with stubs.
func decodeEnvelopeData(t *testing.T, out string) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal([]byte(out), &envelope); err != nil {
t.Fatalf("failed to decode envelope: %v\nraw=%s", err, out)
}
if ok, _ := envelope["ok"].(bool); !ok {
t.Fatalf("envelope.ok=false: %#v", envelope)
}
data, _ := envelope["data"].(map[string]interface{})
return data
}
// toolOutputStub builds an httpmock stub for the One-OpenAPI invoke_read
// or invoke_write endpoint. `outputJSON` is the JSON string the tool
// returns in data.output.
func toolOutputStub(token, kind string, outputJSON string) *httpmock.Stub {
suffix := "invoke_read"
if kind == "write" {
suffix = "invoke_write"
}
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + token + "/tools/" + suffix,
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"output": outputJSON,
},
},
}
}
// commonArgsURL is the typical --url and --sheet-id pair used by sheet-
// level tests.
const (
testToken = "shtcnTestTOK"
testURL = "https://example.feishu.cn/sheets/shtcnTestTOK"
testSheetID = "shtSubA"
testSheetID2 = "shtSubB"
)

View File

@@ -1,208 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Command gen regenerates flag_defs_gen.go and flag_schemas_gen.go from the
// data/*.json spec artifacts, so command startup pays no JSON unmarshal.
//
// Invoked via `go generate ./shortcuts/sheets/...` (see ../../generate.go).
// data/*.json stays the canonical source (synced from sheet-skill-spec); the
// *_gen.go files are committed, derived artifacts. CI should run go generate
// and fail on a dirty tree to keep them in lockstep.
package main
import (
"bytes"
"encoding/json"
"fmt"
"go/format"
"log"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
)
type flagDef struct {
Name string `json:"name"`
Kind string `json:"kind"`
Type string `json:"type"`
Required string `json:"required"`
Desc string `json:"desc"`
Default string `json:"default"`
Hidden bool `json:"hidden"`
Enum []string `json:"enum"`
Input []string `json:"input"`
}
type commandDef struct {
Risk string `json:"risk"`
Flags []flagDef `json:"flags"`
}
// sheetsDir resolves shortcuts/sheets from this generator's own location, so
// the tool works regardless of the caller's working directory.
func sheetsDir() string {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
log.Fatal("gen: cannot resolve caller path")
}
// thisFile = <repo>/shortcuts/sheets/internal/gen/main.go
return filepath.Join(filepath.Dir(thisFile), "..", "..")
}
func writeFormatted(path string, b *bytes.Buffer) {
out, err := format.Source(b.Bytes())
if err != nil {
log.Fatalf("gen: format %s: %v", filepath.Base(path), err)
}
if err := os.WriteFile(path, out, 0o644); err != nil {
log.Fatal(err)
}
fmt.Printf("wrote %s (%d bytes)\n", filepath.Base(path), len(out))
}
func main() {
dir := sheetsDir()
genFlagDefs(dir)
genFlagSchemas(dir)
}
const flagDefsHeader = `// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Code generated from data/flag-defs.json; DO NOT EDIT.
package sheets
// flagDefs is the compiled form of data/flag-defs.json — every CLI flag's
// metadata for every shortcut, emitted as a Go literal so command startup
// pays no JSON unmarshal (see flag_defs.go). Do not hand-edit; regenerate
// with ` + "`go generate ./shortcuts/sheets/...`" + ` after data/flag-defs.json
// changes.
var flagDefs = map[string]commandDef{
`
func sliceLit(s []string) string {
parts := make([]string, len(s))
for i, v := range s {
parts[i] = fmt.Sprintf("%q", v)
}
return "[]string{" + strings.Join(parts, ", ") + "}"
}
func flagLit(f flagDef) string {
var p []string
if f.Name != "" {
p = append(p, fmt.Sprintf("Name: %q", f.Name))
}
if f.Kind != "" {
p = append(p, fmt.Sprintf("Kind: %q", f.Kind))
}
if f.Type != "" {
p = append(p, fmt.Sprintf("Type: %q", f.Type))
}
if f.Required != "" {
p = append(p, fmt.Sprintf("Required: %q", f.Required))
}
if f.Desc != "" {
p = append(p, fmt.Sprintf("Desc: %q", f.Desc))
}
if f.Default != "" {
p = append(p, fmt.Sprintf("Default: %q", f.Default))
}
if f.Hidden {
p = append(p, "Hidden: true")
}
if f.Enum != nil {
p = append(p, "Enum: "+sliceLit(f.Enum))
}
if f.Input != nil {
p = append(p, "Input: "+sliceLit(f.Input))
}
return "{" + strings.Join(p, ", ") + "}"
}
func genFlagDefs(dir string) {
raw, err := os.ReadFile(filepath.Join(dir, "data", "flag-defs.json"))
if err != nil {
log.Fatal(err)
}
var defs map[string]commandDef
if err := json.Unmarshal(raw, &defs); err != nil {
log.Fatal(err)
}
keys := make([]string, 0, len(defs))
for k := range defs {
keys = append(keys, k)
}
sort.Strings(keys)
var b bytes.Buffer
b.WriteString(flagDefsHeader)
for _, k := range keys {
cd := defs[k]
fmt.Fprintf(&b, "%q: {\n", k)
if cd.Risk != "" {
fmt.Fprintf(&b, "Risk: %q,\n", cd.Risk)
}
if cd.Flags != nil {
b.WriteString("Flags: []flagDef{\n")
for _, f := range cd.Flags {
b.WriteString(flagLit(f))
b.WriteString(",\n")
}
b.WriteString("},\n")
}
b.WriteString("},\n")
}
b.WriteString("}\n")
writeFormatted(filepath.Join(dir, "flag_defs_gen.go"), &b)
}
const flagSchemasHeader = `// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Code generated from data/flag-schemas.json; DO NOT EDIT.
package sheets
// commandsWithSchema is the set of shortcut commands that have at least one
// introspectable composite flag in data/flag-schemas.json. Codegen'd so the
// registration loop (shortcuts.go) and the validate fast-path can gate on it
// without parsing the 256KB schema blob at startup (that parse used to run on
// every CLI invocation, sheets or not). The 256KB is now only unmarshaled
// on --print-schema or when validating a command that is in this set. Do not
// hand-edit; regenerate with ` + "`go generate ./shortcuts/sheets/...`" + `.
var commandsWithSchema = map[string]struct{}{
`
func genFlagSchemas(dir string) {
raw, err := os.ReadFile(filepath.Join(dir, "data", "flag-schemas.json"))
if err != nil {
log.Fatal(err)
}
var doc struct {
Flags map[string]json.RawMessage `json:"flags"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
log.Fatal(err)
}
keys := make([]string, 0, len(doc.Flags))
for k := range doc.Flags {
keys = append(keys, k)
}
sort.Strings(keys)
var b bytes.Buffer
b.WriteString(flagSchemasHeader)
for _, k := range keys {
fmt.Fprintf(&b, "%q: {},\n", k)
}
b.WriteString("}\n")
writeFormatted(filepath.Join(dir, "flag_schemas_gen.go"), &b)
}

View File

@@ -1,502 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_batch_update ──────────────────────────────────────────
//
// One tool (batch_update), four shortcuts:
//
// - +batch-update user supplies a CLI-shape operations array
// [{shortcut, input}, ...]; CLI translates to
// MCP shape {tool_name, input(+operation)} via
// batchOpDispatch before invoking the tool
// (high-risk-write — anything in batchOpDispatch
// can be inside)
// - +cells-batch-set-style fan a single style across many ranges
// - +dropdown-update install/replace the same dropdown across
// many ranges in one atomic batch
// - +dropdown-delete clear data_validation across many ranges
// (high-risk-write)
//
// The tool's contract (post-translation):
// { excel_id, operations: [{tool_name, input}, ...], continue_on_error? }
//
// continue_on_error defaults to false (strict transaction): any failure
// rolls back the whole batch. CLI leaves the default in place for the
// three "fan-out" shortcuts since they're meant to be all-or-nothing;
// only +batch-update lets callers flip it via --continue-on-error.
// BatchUpdate accepts a CLI-shape operations array (each item
// {shortcut, input}); on Validate / DryRun / Execute we translate each
// sub-op via batchOpDispatch (see batch_op_dispatch.go) into the MCP
// {tool_name, input(+operation)} form before calling the underlying
// batch_update tool.
var BatchUpdate = common.Shortcut{
Service: "sheets",
Command: "+batch-update",
Description: "Execute a batch of write shortcuts as a single atomic request (rolls back on failure by default).",
Risk: "high-risk-write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+batch-update"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
// Run the full translation in Validate so shape errors surface before
// DryRun / Execute. Translator is pure (no network), so re-running it
// in DryRun / Execute below is fine.
if _, err := batchUpdateInput(runtime, token); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
input, _ := batchUpdateInput(runtime, token)
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
input, err := batchUpdateInput(runtime, token)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Default is strict transaction — any sub-tool failure rolls the whole batch back. Pass --continue-on-error to keep partial successes.",
"Each sub-op is {shortcut, input}. Do NOT pass input.operation (implied by shortcut name) or input.excel_id / input.url (set at the +batch-update top level).",
},
}
// batchUpdateInput translates the user-supplied CLI-shape operations array
// into the MCP batch_update payload. Returns FlagErrorf-typed errors on
// any per-op shape problem (translator validates each entry).
func batchUpdateInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) {
rawOps, err := parseBatchOperationsFlag(runtime)
if err != nil {
return nil, err
}
translated, err := translateBatchOperations(rawOps, token)
if err != nil {
return nil, err
}
input := map[string]interface{}{
"excel_id": token,
"operations": translated,
}
if runtime.Changed("continue-on-error") {
// An explicit --continue-on-error always wins over the envelope, so
// --continue-on-error=false keeps the strict-transaction default even
// when the --operations envelope carries continue_on_error:true.
if runtime.Bool("continue-on-error") {
input["continue_on_error"] = true
}
} else if envelope, _ := parseJSONFlag(runtime, "operations"); envelope != nil {
// No explicit flag: honor an inline override when --operations is an
// envelope object rather than a bare operations array.
if m, ok := envelope.(map[string]interface{}); ok {
if v, ok := m["continue_on_error"].(bool); ok && v {
input["continue_on_error"] = true
}
}
}
return input, nil
}
// parseBatchOperationsFlag accepts --operations as either a JSON array (the
// operations list directly) or an envelope object { operations, continue_on_error }
// for back-compat with the legacy --data shape. Returns the operations array.
func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
v, err := parseJSONFlag(runtime, "operations")
if err != nil {
return nil, err
}
if v == nil {
return nil, common.FlagErrorf("--operations is required")
}
if arr, ok := v.([]interface{}); ok {
return arr, nil
}
if m, ok := v.(map[string]interface{}); ok {
if ops, ok := m["operations"].([]interface{}); ok {
return ops, nil
}
}
return nil, common.FlagErrorf("--operations must be a JSON array (or { operations: [...] } envelope)")
}
// CellsBatchSetStyle stamps one style block across many sheet-prefixed
// ranges atomically. --ranges is a JSON array of sheet-prefixed A1
// strings; the style is composed from the same flat flags as
// +cells-set-style. CLI fans each range into a separate set_cell_range
// op inside one batch_update.
var CellsBatchSetStyle = common.Shortcut{
Service: "sheets",
Command: "+cells-batch-set-style",
Description: "Apply one style block to many sheet-prefixed ranges in one atomic batch.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-batch-set-style"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
if _, err := validateDropdownRanges(runtime); err != nil {
return err
}
if err := requireAnyStyleFlag(runtime); err != nil {
return err
}
if _, err := borderStylesFromFlag(runtime); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
input, _ := cellsBatchSetStyleInput(runtime, token)
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
input, err := cellsBatchSetStyleInput(runtime, token)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) {
ranges, err := validateDropdownRanges(runtime)
if err != nil {
return nil, err
}
cellStyle := buildCellStyleFromFlags(runtime)
borderStyles, err := borderStylesFromFlag(runtime)
if err != nil {
return nil, err
}
prototype := map[string]interface{}{}
if len(cellStyle) > 0 {
prototype["cell_styles"] = cellStyle
}
if borderStyles != nil {
prototype["border_styles"] = borderStyles
}
var ops []interface{}
for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil {
return nil, err
}
rows, cols, err := rangeDimensions(sub)
if err != nil {
return nil, common.FlagErrorf("range %q: %v", rng, err)
}
cells := fillCellsMatrix(rows, cols, prototype)
ops = append(ops, map[string]interface{}{
"tool_name": "set_cell_range",
"input": map[string]interface{}{
"excel_id": token,
"sheet_name": sheet,
"range": sub,
"cells": cells,
},
})
}
return map[string]interface{}{
"excel_id": token,
"operations": ops,
}, nil
}
// CellsBatchClear clears content / formats / both across many sheet-prefixed
// ranges in one atomic batch. --ranges is a JSON array of sheet-prefixed A1
// strings; --scope reuses the +cells-clear vocabulary (content / formats /
// all). CLI fans each range into a separate clear_cell_range op inside one
// batch_update. high-risk-write because clear is irreversible.
var CellsBatchClear = common.Shortcut{
Service: "sheets",
Command: "+cells-batch-clear",
Description: "Clear content/formats across many sheet-prefixed ranges in one atomic batch (irreversible).",
Risk: "high-risk-write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-batch-clear"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
if _, err := validateDropdownRanges(runtime); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
input, _ := cellsBatchClearInput(runtime, token)
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
input, err := cellsBatchClearInput(runtime, token)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
if err != nil {
return annotateEmbeddedBlockClearErr(err)
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"high-risk-write — always preview with --dry-run; clear is not undoable.",
"Every --ranges item must carry a sheet prefix (e.g. \"Sheet1!A1:A10\"); all ranges are cleared with the same --scope.",
"Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.",
},
}
func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) {
ranges, err := validateDropdownRanges(runtime)
if err != nil {
return nil, err
}
clearType := normalizeClearType(runtime.Str("scope"))
var ops []interface{}
for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil {
return nil, err
}
ops = append(ops, map[string]interface{}{
"tool_name": "clear_cell_range",
"input": map[string]interface{}{
"excel_id": token,
"sheet_name": sheet,
"range": sub,
"clear_type": clearType,
},
})
}
return map[string]interface{}{
"excel_id": token,
"operations": ops,
}, nil
}
// DropdownUpdate installs/replaces a single dropdown on many ranges in one
// atomic batch. Sheet ids come from the per-range sheet prefix.
var DropdownUpdate = common.Shortcut{
Service: "sheets",
Command: "+dropdown-update",
Description: "Install or replace one dropdown across many sheet-prefixed ranges atomically.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dropdown-update"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
if _, err := validateDropdownRanges(runtime); err != nil {
return err
}
if _, err := validateDropdownSourceOrOptions(runtime); err != nil {
return err
}
warnDropdownSourceRangeHighlight(runtime)
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
input, _ := dropdownBatchInput(runtime, token, false)
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
input, err := dropdownBatchInput(runtime, token, false)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// DropdownDelete clears data_validation across many ranges atomically.
var DropdownDelete = common.Shortcut{
Service: "sheets",
Command: "+dropdown-delete",
Description: "Clear dropdowns from many sheet-prefixed ranges atomically (irreversible).",
Risk: "high-risk-write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dropdown-delete"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
ranges, err := validateDropdownRanges(runtime)
if err != nil {
return err
}
if len(ranges) > 100 {
return common.FlagErrorf("--ranges accepts at most 100 entries; got %d", len(ranges))
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
input, _ := dropdownBatchInput(runtime, token, true)
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
input, err := dropdownBatchInput(runtime, token, true)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// dropdownBatchInput builds the batch_update payload for both
// +dropdown-update (clear=false, data_validation populated) and
// +dropdown-delete (clear=true, data_validation: null).
func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool) (map[string]interface{}, error) {
ranges, err := validateDropdownRanges(runtime)
if err != nil {
return nil, err
}
var prototype map[string]interface{}
if clear {
prototype = map[string]interface{}{"data_validation": nil}
} else {
validation, err := buildDropdownValidation(runtime)
if err != nil {
return nil, err
}
prototype = map[string]interface{}{"data_validation": validation}
}
var ops []interface{}
for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil {
return nil, err
}
rows, cols, err := rangeDimensions(sub)
if err != nil {
return nil, common.FlagErrorf("range %q: %v", rng, err)
}
cells := fillCellsMatrix(rows, cols, prototype)
ops = append(ops, map[string]interface{}{
"tool_name": "set_cell_range",
"input": map[string]interface{}{
"excel_id": token,
"sheet_name": sheet,
"range": sub,
"cells": cells,
},
})
}
return map[string]interface{}{
"excel_id": token,
"operations": ops,
}, nil
}
// ─── helpers resurrected from B3 (used here + future skills) ──────────
// validateDropdownRanges parses --ranges, requires every entry to carry a
// sheet prefix, and returns the parsed list.
func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
raw, err := requireJSONArray(runtime, "ranges")
if err != nil {
return nil, err
}
out := make([]string, 0, len(raw))
for i, v := range raw {
s, ok := v.(string)
if !ok {
return nil, common.FlagErrorf("--ranges[%d] must be a string", i)
}
s = strings.TrimSpace(s)
if !strings.Contains(s, "!") {
return nil, common.FlagErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s)
}
// Validate the sheet!range shape up front so malformed entries like
// "!A1" (no sheet), "Sheet1!" (no range) or "Sheet1!bad" (bad ref) fail
// here at Validate instead of slipping through to DryRun/Execute.
_, sub, err := splitSheetPrefixedRange(s)
if err != nil {
return nil, common.FlagErrorf("--ranges[%d]: %v", i, err)
}
if _, _, err := rangeDimensions(sub); err != nil {
return nil, common.FlagErrorf("--ranges[%d] (%q): %v", i, s, err)
}
out = append(out, s)
}
return out, nil
}
// splitSheetPrefixedRange splits "sheet1!A2:A100" into ("sheet1", "A2:A100").
func splitSheetPrefixedRange(rng string) (sheet, sub string, err error) {
idx := strings.Index(rng, "!")
if idx <= 0 || idx == len(rng)-1 {
return "", "", common.FlagErrorf("range %q must use sheet!range form", rng)
}
return strings.TrimSpace(rng[:idx]), strings.TrimSpace(rng[idx+1:]), nil
}

View File

@@ -1,495 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
)
// TestBatchUpdate_TranslatesShortcutToToolName verifies +batch-update
// translates each CLI-shape sub-op ({shortcut, input}) to the MCP-shape
// ({tool_name, input(+operation, +excel_id)}) before threading into
// the underlying batch_update tool. Covers continue_on_error too.
func TestBatchUpdate_TranslatesShortcutToToolName(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[
{"shortcut":"+cells-set","input":{"sheet_id":"sh1","range":"A1","cells":[[{"value":42}]]}},
{"shortcut":"+dim-insert","input":{"sheet_id":"sh1","position":"1","count":3}}
]`,
"--continue-on-error",
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
if len(ops) != 2 {
t.Fatalf("operations length = %d, want 2", len(ops))
}
if input["continue_on_error"] != true {
t.Errorf("continue_on_error = %v, want true", input["continue_on_error"])
}
// op[0]: +cells-set → set_cell_range, no operation field
op0 := ops[0].(map[string]interface{})
if op0["tool_name"] != "set_cell_range" {
t.Errorf("op[0].tool_name = %v, want set_cell_range", op0["tool_name"])
}
in0, _ := op0["input"].(map[string]interface{})
if in0["excel_id"] == nil {
t.Errorf("op[0].input.excel_id missing (translator should inject)")
}
if _, has := in0["operation"]; has {
t.Errorf("op[0].input.operation present, +cells-set should not inject one: %#v", in0)
}
// op[1]: +dim-insert → modify_sheet_structure + operation:"insert"
op1 := ops[1].(map[string]interface{})
if op1["tool_name"] != "modify_sheet_structure" {
t.Errorf("op[1].tool_name = %v, want modify_sheet_structure", op1["tool_name"])
}
in1, _ := op1["input"].(map[string]interface{})
if in1["operation"] != "insert" {
t.Errorf("op[1].input.operation = %v, want \"insert\"", in1["operation"])
}
}
func TestBatchUpdate_HighRiskWriteRequiresYes(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[{"shortcut":"+cells-set","input":{}}]`,
})
if err == nil {
t.Fatalf("expected confirmation_required; stdout=%s stderr=%s", stdout, stderr)
}
}
// TestCellsBatchSetStyle_FansOutOps verifies multiple ranges produce one
// set_cell_range op each, sharing the same style flags.
func TestCellsBatchSetStyle_FansOutOps(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, CellsBatchSetStyle, []string{
"--url", testURL,
"--ranges", `["sheet1!A1:B2","sheet1!D1:E2","sheet1!A5:A6"]`,
"--font-weight", "bold",
"--background-color", "#ffff00",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
if len(ops) != 3 {
t.Fatalf("operations length = %d, want 3 (one per range)", len(ops))
}
for i, raw := range ops {
op, _ := raw.(map[string]interface{})
if op["tool_name"] != "set_cell_range" {
t.Errorf("op[%d].tool_name = %v, want set_cell_range", i, op["tool_name"])
}
params, _ := op["input"].(map[string]interface{})
if params["sheet_name"] != "sheet1" {
t.Errorf("op[%d].sheet_name = %v, want sheet1", i, params["sheet_name"])
}
cells, _ := params["cells"].([]interface{})
row, _ := cells[0].([]interface{})
cell, _ := row[0].(map[string]interface{})
style, _ := cell["cell_styles"].(map[string]interface{})
if style["font_weight"] != "bold" || style["background_color"] != "#ffff00" {
t.Errorf("op[%d] cell_styles wrong: %#v", i, style)
}
}
}
// TestCellsBatchClear_FansOutOps verifies multiple ranges produce one
// clear_cell_range op each, all sharing the same --scope-derived clear_type,
// with the sheet prefix split into sheet_name + bare range.
func TestCellsBatchClear_FansOutOps(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, CellsBatchClear, []string{
"--url", testURL,
"--ranges", `["sheet1!A1:A10","sheet2!C1:D5","sheet1!F3"]`,
"--scope", "all",
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
if len(ops) != 3 {
t.Fatalf("operations length = %d, want 3 (one per range)", len(ops))
}
wantSheet := []string{"sheet1", "sheet2", "sheet1"}
wantRange := []string{"A1:A10", "C1:D5", "F3"}
for i, raw := range ops {
op, _ := raw.(map[string]interface{})
if op["tool_name"] != "clear_cell_range" {
t.Errorf("op[%d].tool_name = %v, want clear_cell_range", i, op["tool_name"])
}
params, _ := op["input"].(map[string]interface{})
if params["sheet_name"] != wantSheet[i] {
t.Errorf("op[%d].sheet_name = %v, want %s", i, params["sheet_name"], wantSheet[i])
}
if params["range"] != wantRange[i] {
t.Errorf("op[%d].range = %v, want %s", i, params["range"], wantRange[i])
}
if params["clear_type"] != "all" {
t.Errorf("op[%d].clear_type = %v, want all", i, params["clear_type"])
}
}
}
// TestCellsBatchClear_ScopeDefaultsToContents verifies the default --scope
// (content) maps to the tool's clear_type "contents" — identical to the
// standalone +cells-clear normalization.
func TestCellsBatchClear_ScopeDefaultsToContents(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, CellsBatchClear, []string{
"--url", testURL,
"--ranges", `["sheet1!A1:B2"]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
if len(ops) != 1 {
t.Fatalf("operations length = %d, want 1", len(ops))
}
params, _ := ops[0].(map[string]interface{})["input"].(map[string]interface{})
if params["clear_type"] != "contents" {
t.Errorf("clear_type = %v, want contents (default scope)", params["clear_type"])
}
}
// TestCellsBatchClear_Guards covers the sheet-prefix requirement and the
// high-risk-write confirmation gate.
func TestCellsBatchClear_Guards(t *testing.T) {
t.Parallel()
// sheetless range → prefix guard (shared with the dropdown fan-outs).
stdout, stderr, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
"--url", testURL,
"--ranges", `["A1:A10"]`,
"--yes",
"--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") {
t.Errorf("expected sheet-prefix guard; got=%s|%s|%v", stdout, stderr, err)
}
// missing --yes → confirmation_required (high-risk-write).
stdout, stderr, err = runShortcutCapturingErr(t, CellsBatchClear, []string{
"--url", testURL,
"--ranges", `["sheet1!A1:A10"]`,
})
if err == nil {
t.Errorf("expected confirmation_required without --yes; stdout=%s stderr=%s", stdout, stderr)
}
}
// TestDropdownUpdate_BatchPayload verifies the multi-range dropdown
// update fans out into a single batch_update with one set_cell_range
// op per range. Also covers --colors / --highlight -> highlight_colors
// / enable_highlight propagation through dropdownBatchInput.
func TestDropdownUpdate_BatchPayload(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", `["sheet1!A2:A5","sheet1!C2:C5"]`,
"--options", `["a","b","c"]`,
"--colors", `["#FFE699","#bff7d9","#ffb3b3"]`,
"--multiple", "--highlight",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
if len(ops) != 2 {
t.Fatalf("operations length = %d, want 2", len(ops))
}
for i, raw := range ops {
op, _ := raw.(map[string]interface{})
params, _ := op["input"].(map[string]interface{})
cells, _ := params["cells"].([]interface{})
if len(cells) != 4 {
t.Errorf("op[%d] cells rows = %d, want 4 (A2:A5 / C2:C5)", i, len(cells))
}
row0, _ := cells[0].([]interface{})
cell, _ := row0[0].(map[string]interface{})
dv, _ := cell["data_validation"].(map[string]interface{})
if dv == nil || dv["type"] != "list" {
t.Errorf("op[%d] missing data_validation list: %#v", i, cell)
}
items, _ := dv["items"].([]interface{})
if len(items) != 3 {
t.Errorf("op[%d] data_validation.items length = %d, want 3", i, len(items))
}
if dv["support_multiple_values"] != true {
t.Errorf("op[%d] support_multiple_values = %v, want true", i, dv["support_multiple_values"])
}
colors, _ := dv["highlight_colors"].([]interface{})
if len(colors) != 3 {
t.Errorf("op[%d] highlight_colors length = %d, want 3", i, len(colors))
}
if dv["enable_highlight"] != true {
t.Errorf("op[%d] enable_highlight = %v, want true", i, dv["enable_highlight"])
}
}
}
// TestDropdownDelete_BatchClearsValidation verifies delete sets
// data_validation: null on every cell.
func TestDropdownDelete_BatchClearsValidation(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, DropdownDelete, []string{
"--url", testURL,
"--ranges", `["sheet1!A2:A4"]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
if len(ops) != 1 {
t.Fatalf("operations length = %d, want 1", len(ops))
}
op := ops[0].(map[string]interface{})
params, _ := op["input"].(map[string]interface{})
cells, _ := params["cells"].([]interface{})
for i, raw := range cells {
row, _ := raw.([]interface{})
cell, _ := row[0].(map[string]interface{})
if _, present := cell["data_validation"]; !present {
t.Errorf("row %d: data_validation key missing", i)
continue
}
if cell["data_validation"] != nil {
t.Errorf("row %d: data_validation = %v, want null", i, cell["data_validation"])
}
}
}
func TestBatchUpdate_ValidationGuards(t *testing.T) {
t.Parallel()
// dropdown-update with sheetless range
stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", `["A2:A5"]`,
"--options", `["a"]`,
"--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") {
t.Errorf("expected sheet-prefix guard for +dropdown-update; got=%s|%s|%v", stdout, stderr, err)
}
// batch-update with empty operations
stdout, stderr, err = runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[]`,
"--yes",
"--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "non-empty JSON array") {
t.Errorf("expected empty-operations guard; got=%s|%s|%v", stdout, stderr, err)
}
// dropdown-update with non-array --options (object instead) → array guard
// (now via schema validator at parseJSONFlag time)
stdout, stderr, err = runShortcutCapturingErr(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", `["sheet1!A1:A2"]`,
"--options", `{"not":"array"}`,
"--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), `expected type "array"`) {
t.Errorf("expected JSON array guard; got=%s|%s|%v", stdout, stderr, err)
}
}
// TestValidateDropdownRanges_RejectsMalformedRange locks the up-front sheet!range
// validation: entries that merely contain "!" but are otherwise malformed (empty
// sheet, empty range, or an unparseable A1 ref) must fail at Validate rather than
// slip through to DryRun/Execute. Covers +dropdown-update / +dropdown-delete,
// which fan out over --ranges.
func TestValidateDropdownRanges_RejectsMalformedRange(t *testing.T) {
t.Parallel()
cases := []struct {
name string
ranges string
want string
}{
{"no sheet prefix at all", `["A1:A5"]`, "must include a sheet prefix"},
{"empty sheet name", `["!A1:A5"]`, "must use sheet!range form"},
{"empty range after prefix", `["Sheet1!"]`, "must use sheet!range form"},
{"unparseable ref", `["Sheet1!bad"]`, "invalid cell ref"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", tc.ranges,
"--options", `["a"]`,
"--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), tc.want) {
t.Errorf("ranges=%s: expected error containing %q; got=%s|%s|%v", tc.ranges, tc.want, stdout, stderr, err)
}
})
}
}
// TestBatchUpdate_TranslatorRejects covers per-op shape errors caught by
// translateBatchOp: unknown shortcut, missing shortcut, banned (read /
// fan-out / legacy v2) shortcuts, hand-filled reserved keys, etc.
func TestBatchUpdate_TranslatorRejects(t *testing.T) {
t.Parallel()
cases := []struct {
name string
opsJSON string
wantMatch string
}{
{
name: "missing shortcut field",
opsJSON: `[{"input":{"range":"A1"}}]`,
wantMatch: "'shortcut' field is required",
},
{
name: "empty shortcut string",
opsJSON: `[{"shortcut":"","input":{}}]`,
wantMatch: "'shortcut' must be a non-empty string",
},
{
name: "unknown shortcut",
opsJSON: `[{"shortcut":"+cells-set-magic","input":{}}]`,
wantMatch: "not allowed in +batch-update",
},
{
name: "read op rejected",
opsJSON: `[{"shortcut":"+cells-get","input":{}}]`,
wantMatch: "not allowed in +batch-update",
},
{
name: "nested batch-update rejected",
opsJSON: `[{"shortcut":"+batch-update","input":{}}]`,
wantMatch: "not allowed in +batch-update",
},
{
name: "fan-out wrapper rejected",
opsJSON: `[{"shortcut":"+cells-batch-set-style","input":{}}]`,
wantMatch: "not allowed in +batch-update",
},
{
name: "fan-out wrapper +cells-batch-clear rejected",
opsJSON: `[{"shortcut":"+cells-batch-clear","input":{}}]`,
wantMatch: "not allowed in +batch-update",
},
{
name: "legacy v2 +dim-move rejected",
opsJSON: `[{"shortcut":"+dim-move","input":{}}]`,
wantMatch: "not allowed in +batch-update",
},
{
name: "user filled operation manually",
opsJSON: `[{"shortcut":"+dim-insert","input":{"operation":"delete","position":"1","count":1}}]`,
wantMatch: "do not pass input.operation",
},
{
name: "user filled excel_id",
opsJSON: `[{"shortcut":"+cells-set","input":{"excel_id":"shtcnX","range":"A1"}}]`,
wantMatch: "do not pass input.excel_id",
},
{
name: "user filled url",
opsJSON: `[{"shortcut":"+cells-set","input":{"url":"https://x.feishu.cn/sheets/sh","range":"A1"}}]`,
wantMatch: "do not pass input.url",
},
{
name: "extra top-level key",
opsJSON: `[{"shortcut":"+cells-set","input":{"range":"A1"},"tool_name":"oops"}]`,
wantMatch: "unknown top-level key",
},
{
name: "sub-op not an object",
opsJSON: `["not-an-object"]`,
wantMatch: "must be a JSON object",
},
{
name: "input not an object",
opsJSON: `[{"shortcut":"+cells-set","input":"not-an-object"}]`,
wantMatch: "'input' must be a JSON object",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", tc.opsJSON,
"--yes",
"--dry-run",
})
if err == nil {
t.Fatalf("expected error containing %q; got stdout=%s stderr=%s", tc.wantMatch, stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), tc.wantMatch) {
t.Errorf("expected error containing %q; got: %s | %s | %v", tc.wantMatch, stdout, stderr, err)
}
})
}
}
// TestBatchUpdate_DimFreezeInjectsFreeze covers the static-freeze-only
// path: +dim-freeze always injects operation=freeze (count==0 unfreeze
// path of the single shortcut is intentionally not supported in batch).
func TestBatchUpdate_DimFreezeInjectsFreeze(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[{"shortcut":"+dim-freeze","input":{"sheet_id":"sh1","dimension":"row","count":2}}]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
op := ops[0].(map[string]interface{})
if op["tool_name"] != "modify_sheet_structure" {
t.Errorf("tool_name = %v, want modify_sheet_structure", op["tool_name"])
}
in, _ := op["input"].(map[string]interface{})
if in["operation"] != "freeze" {
t.Errorf("operation = %v, want \"freeze\"", in["operation"])
}
}
// TestBatchUpdate_ResizeNoOperationField covers the resize_range dispatch:
// mapping has no operationField, so input.operation must NOT be injected.
func TestBatchUpdate_ResizeNoOperationField(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","range":"1:3","type":"pixel","size":30}}]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
op := input["operations"].([]interface{})[0].(map[string]interface{})
if op["tool_name"] != "resize_range" {
t.Errorf("tool_name = %v, want resize_range", op["tool_name"])
}
in, _ := op["input"].(map[string]interface{})
if _, has := in["operation"]; has {
t.Errorf("operation should NOT be injected for resize_range; got %#v", in)
}
}
// TestSplitSheetPrefixedRange exercises the helper directly.
func TestSplitSheetPrefixedRange(t *testing.T) {
t.Parallel()
sheet, sub, err := splitSheetPrefixedRange("sheet1!A2:A100")
if err != nil || sheet != "sheet1" || sub != "A2:A100" {
t.Errorf("split = (%q,%q,%v), want (sheet1, A2:A100, nil)", sheet, sub, err)
}
if _, _, err := splitSheetPrefixedRange("A2:A100"); err == nil {
t.Error("expected error on missing prefix")
}
if _, _, err := splitSheetPrefixedRange("!A2"); err == nil {
t.Error("expected error on empty sheet name")
}
// Compile-time use of json import
_ = json.Marshal
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,672 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
// TestPivotPlacementWarn pins the advisory that fires only on the risky
// +pivot-create combination — an explicit placement sheet with no offset —
// and stays silent (or only conditionally reminds) everywhere else.
func TestPivotPlacementWarn(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw map[string]interface{}
want string // "" none | "definite" names the sheet | "conditional" generic reminder
}{
{"no placement target → silent (default sub-sheet)",
map[string]interface{}{"source": "'Sheet1'!A1:D100"}, ""},
{"target-position offset → silent",
map[string]interface{}{"target-sheet-name": "Sheet1", "source": "'Sheet1'!A1:D100", "target-position": "H1"}, ""},
{"range offset → silent",
map[string]interface{}{"target-sheet-id": "sht_x", "range": "H1"}, ""},
{"target name == source sheet, no offset → definite",
map[string]interface{}{"target-sheet-name": "Sheet1", "source": "'Sheet1'!A1:D100"}, "definite"},
{"case-insensitive name match → definite",
map[string]interface{}{"target-sheet-name": "sheet1", "source": "'Sheet1'!A1:D100"}, "definite"},
{"target name != source sheet → silent (distinct sheet is safe)",
map[string]interface{}{"target-sheet-name": "PivotOut", "source": "'Sheet1'!A1:D100"}, ""},
{"target by id, no offset → conditional",
map[string]interface{}{"target-sheet-id": "sht_abc", "source": "'Sheet1'!A1:D100"}, "conditional"},
{"target name but source lacks prefix → conditional",
map[string]interface{}{"target-sheet-name": "Sheet1", "source": "A1:D100"}, "conditional"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := pivotPlacementWarn(mapFlagView{raw: tc.raw})
switch tc.want {
case "":
if got != "" {
t.Errorf("expected no warning, got %q", got)
}
case "definite":
if !strings.Contains(got, "--target-sheet-name") {
t.Errorf("expected definite warning citing --target-sheet-name, got %q", got)
}
case "conditional":
if !strings.Contains(got, "a placement sheet is set") {
t.Errorf("expected conditional reminder, got %q", got)
}
}
})
}
}
// TestSheetNameFromA1 covers the source-sheet extraction used by the placement
// warning: prefix detection, single-quote stripping, and the no-prefix case.
func TestSheetNameFromA1(t *testing.T) {
t.Parallel()
tests := []struct{ in, want string }{
{"'Sheet1'!A1:D100", "Sheet1"},
{"Data!A1", "Data"},
{"'My Sheet'!A1:B2", "My Sheet"},
{"A1:D100", ""},
{"", ""},
{" 'X'!A1 ", "X"},
}
for _, tc := range tests {
if got := sheetNameFromA1(tc.in); got != tc.want {
t.Errorf("sheetNameFromA1(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// TestObjectCRUDShortcuts_DryRun walks the create / update / delete trio
// for each object skill. Together these cover all 21 CRUD shortcuts plus
// the per-object id flag renames (rule-id, group-id, view-id, etc.).
func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
t.Parallel()
type spec struct {
name string
sc common.Shortcut
args []string
toolName string
wantInput map[string]interface{}
}
tests := []spec{
// chart
{
name: "+chart-create",
sc: ChartCreate,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--properties", `{"type":"line","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`},
toolName: "manage_chart_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "create",
"properties": map[string]interface{}{
"type": "line",
"position": map[string]interface{}{"row": float64(0), "col": "A"},
"size": map[string]interface{}{"width": float64(400), "height": float64(300)},
},
},
},
{
name: "+chart-update",
sc: ChartUpdate,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chartXYZ", "--properties", `{"type":"bar","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`},
toolName: "manage_chart_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "update",
"chart_id": "chartXYZ",
"properties": map[string]interface{}{
"type": "bar",
"position": map[string]interface{}{"row": float64(0), "col": "A"},
"size": map[string]interface{}{"width": float64(400), "height": float64(300)},
},
},
},
// pivot — has extra create flags incl. required --source.
// --target-sheet-id is the placement target (where the pivot lands);
// the placement selector is renamed from the generic --sheet-id /
// --sheet-name to --target-sheet-id / --target-sheet-name to keep
// it semantically distinct from the data-source sheet (which is
// encoded inside --source as `'SheetName'!Range`).
// pivotSpec.allowEmptySheetSelectorOnCreate lets both target
// selectors be omitted so the backend auto-creates a sub-sheet —
// covered separately in the +pivot-create empty-selector / mutex
// tests below.
{
name: "+pivot-create with placement / source / range flags",
sc: PivotCreate,
args: []string{
"--url", testURL, "--target-sheet-id", testSheetID,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
"--range", "F1",
"--target-position", "B5",
},
toolName: "manage_pivot_table_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "create",
"target_position": "B5",
"properties": map[string]interface{}{
"rows": []interface{}{map[string]interface{}{"field": "A"}},
"source": "Sheet1!A1:F1000",
"range": "F1",
},
},
},
// +pivot-create accepts both target selectors empty — backend
// auto-creates a placement sub-sheet.
{
name: "+pivot-create empty --target-sheet-id / --target-sheet-name omits sheet from input",
sc: PivotCreate,
args: []string{
"--url", testURL,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
},
toolName: "manage_pivot_table_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "create",
"properties": map[string]interface{}{
"rows": []interface{}{map[string]interface{}{"field": "A"}},
"source": "Sheet1!A1:F1000",
},
},
},
{
name: "+pivot-delete",
sc: PivotDelete,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--pivot-table-id", "ptA"},
toolName: "manage_pivot_table_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "delete",
"pivot_table_id": "ptA",
},
},
// cond-format — --rule-id rename + --rule-type / --ranges hoist.
// rule_type lives at properties.rule_type (flat string), not nested
// under a `rule` object; enum vocabulary matches server schema
// (cellIs / duplicateValues / ... — see mcp-tools.json
// manage_conditional_format_object.properties.rule_type).
{
name: "+cond-format-update id rename + rule-type/ranges",
sc: CondFormatUpdate,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--rule-id", "ruleA",
"--properties", `{"attrs":[{"operator":"greaterThan","value":"100"}],"style":{"back_color":"#FFD7D7"}}`,
"--rule-type", "cellIs",
"--ranges", `["A1:A100"]`,
},
toolName: "manage_conditional_format_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "update",
"conditional_format_id": "ruleA",
"properties": map[string]interface{}{
"rule_type": "cellIs",
"attrs": []interface{}{map[string]interface{}{"operator": "greaterThan", "value": "100"}},
"style": map[string]interface{}{"back_color": "#FFD7D7"},
"ranges": []interface{}{"A1:A100"},
},
},
},
// filter — special, no id flag
{
name: "+filter-create without --properties sends properties.range only",
sc: FilterCreate,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F1000", "--properties", `{"rules":[]}`},
toolName: "manage_filter_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "create",
"properties": map[string]interface{}{
"range": "A1:F1000",
"rules": []interface{}{},
},
},
},
{
name: "+filter-create with --properties merges rules",
sc: FilterCreate,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F1000", "--properties", `{"rules":[{"column_index":"B","conditions":[{"type":"text","compare_type":"contains","values":["x"]}]}]}`},
toolName: "manage_filter_object",
wantInput: map[string]interface{}{
"properties": map[string]interface{}{
"range": "A1:F1000",
"rules": []interface{}{map[string]interface{}{
"column_index": "B",
"conditions": []interface{}{map[string]interface{}{
"type": "text",
"compare_type": "contains",
"values": []interface{}{"x"},
}},
}},
},
},
},
{
// +filter-delete has no separate --filter-id flag because the
// server contract sets filter_id === sheet_id; the translator
// auto-injects filter_id from --sheet-id. update/delete fail
// hard when only --sheet-name is given (no mid-call lookup).
name: "+filter-delete (sheet-scoped, auto-injects filter_id=sheet_id)",
sc: FilterDelete,
args: []string{"--url", testURL, "--sheet-id", testSheetID},
toolName: "manage_filter_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"filter_id": testSheetID,
"operation": "delete",
},
},
{
// +filter-update auto-injects filter_id from sheet_id, hoists
// --range out of properties, and merges properties.rules.
name: "+filter-update auto-injects filter_id, hoists --range",
sc: FilterUpdate,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:F1000",
"--properties", `{"rules":[{"column_index":"B","conditions":[{"type":"text","compare_type":"contains","values":["x"]}]}]}`,
},
toolName: "manage_filter_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"filter_id": testSheetID,
"operation": "update",
"properties": map[string]interface{}{
"range": "A1:F1000",
"rules": []interface{}{map[string]interface{}{
"column_index": "B",
"conditions": []interface{}{map[string]interface{}{
"type": "text",
"compare_type": "contains",
"values": []interface{}{"x"},
}},
}},
},
},
},
// filter-view CRUD (cli-only via callTool)
{
name: "+filter-view-create",
sc: FilterViewCreate,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:Z100", "--properties", `{"view_name":"v1"}`},
toolName: "manage_filter_view_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "create",
"properties": map[string]interface{}{"view_name": "v1", "range": "A1:Z100"},
},
},
{
name: "+filter-view-update with --view-id",
sc: FilterViewUpdate,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--view-id", "vABC", "--properties", `{"view_name":"renamed"}`},
toolName: "manage_filter_view_object",
wantInput: map[string]interface{}{
"view_id": "vABC",
"operation": "update",
},
},
// sparkline --group-id
{
name: "+sparkline-update --group-id → group_id",
sc: SparklineUpdate,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA", "--properties", `{"type":"line"}`},
toolName: "manage_sparkline_object",
wantInput: map[string]interface{}{
"group_id": "grpA",
"operation": "update",
"properties": map[string]interface{}{"type": "line"},
},
},
{
// happy path for the new sparkline_id check: each
// properties.sparklines[i] carries sparkline_id, so the
// validator passes through cleanly.
name: "+sparkline-update properties.sparklines[] with sparkline_id passes",
sc: SparklineUpdate,
args: []string{
"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA",
"--properties", `{"sparklines":[{"sparkline_id":"sl1","source":"Sheet1!A1:A10"}]}`,
},
toolName: "manage_sparkline_object",
wantInput: map[string]interface{}{
"group_id": "grpA",
"operation": "update",
"properties": map[string]interface{}{
"sparklines": []interface{}{
map[string]interface{}{"sparkline_id": "sl1", "source": "Sheet1!A1:A10"},
},
},
},
},
// float-image — fully hoisted to flat flags
{
name: "+float-image-create with image-token + position/size",
sc: FloatImageCreate,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--image-name", "logo.png",
"--image-token", "tok_xyz",
"--position-row", "2", "--position-col", "D",
"--size-width", "300", "--size-height", "200",
},
toolName: "manage_float_image_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "create",
"properties": map[string]interface{}{
"image_name": "logo.png",
"image_token": "tok_xyz",
"position": map[string]interface{}{"row": float64(2), "col": "D"},
"size": map[string]interface{}{"width": float64(300), "height": float64(200)},
},
},
},
{
// patch mode: position + size with no image source. The image
// fields are omitted so the server keeps the current image; only
// image_name (server-mandated) and the changed geometry are sent.
// This is the shape that used to be rejected CLI-side.
name: "+float-image-update patch position+size, no image source",
sc: FloatImageUpdate,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--float-image-id", "imgABC", "--image-name", "logo.png",
"--position-row", "10", "--position-col", "I",
"--size-width", "90", "--size-height", "70",
},
toolName: "manage_float_image_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "update",
"float_image_id": "imgABC",
"properties": map[string]interface{}{
"image_name": "logo.png",
"position": map[string]interface{}{"row": float64(10), "col": "I"},
"size": map[string]interface{}{"width": float64(90), "height": float64(70)},
},
},
},
{
// swap the image: an explicit --image-token rides alongside the
// mandatory core (image_name + position + size).
name: "+float-image-update swap image via image-token",
sc: FloatImageUpdate,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--float-image-id", "imgABC",
"--image-name", "new.png", "--image-token", "tok_new",
"--position-row", "2", "--position-col", "B",
"--size-width", "300", "--size-height", "200",
},
toolName: "manage_float_image_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "update",
"float_image_id": "imgABC",
"properties": map[string]interface{}{
"image_name": "new.png",
"image_token": "tok_new",
"position": map[string]interface{}{"row": float64(2), "col": "B"},
"size": map[string]interface{}{"width": float64(300), "height": float64(200)},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, tt.sc, tt.args)
got := decodeToolInput(t, body, tt.toolName)
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestPivotCreate_SheetSelectorSemantics locks in the "at most one"
// semantics for +pivot-create (and only +pivot-create): both
// --target-sheet-id and --target-sheet-name may be omitted (backend
// auto-creates a placement sub-sheet), but passing both is rejected.
//
// Companion regression — TestObjectCreate_RequiresSheetSelector below —
// confirms every other *-create still rejects empty selector.
func TestPivotCreate_SheetSelectorSemantics(t *testing.T) {
t.Parallel()
t.Run("both empty is accepted", func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, PivotCreate, []string{
"--url", testURL,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
})
input := decodeToolInput(t, body, "manage_pivot_table_object")
if _, ok := input["sheet_id"]; ok {
t.Errorf("expected no sheet_id in input; got %v", input["sheet_id"])
}
if _, ok := input["sheet_name"]; ok {
t.Errorf("expected no sheet_name in input; got %v", input["sheet_name"])
}
})
t.Run("both set is rejected", func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
"--url", testURL,
"--target-sheet-id", testSheetID,
"--target-sheet-name", "Sheet1",
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
})
if err == nil {
t.Fatalf("expected CLI to reject both --target-sheet-id and --target-sheet-name set; stderr=%s", stderr)
}
combined := stderr + err.Error()
if !strings.Contains(combined, "mutually exclusive") {
t.Errorf("expected error to say 'mutually exclusive'; got=%s|%v", stderr, err)
}
// 错误信息必须用真实的 flag 名target-*),否则模型按消息提示去
// 改 --sheet-id 还是错的。
if !strings.Contains(combined, "--target-sheet-id") {
t.Errorf("expected error to quote --target-sheet-id flag name; got=%s|%v", stderr, err)
}
})
t.Run("only target-sheet-id is accepted", func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, PivotCreate, []string{
"--url", testURL,
"--target-sheet-id", testSheetID,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
})
input := decodeToolInput(t, body, "manage_pivot_table_object")
if got, _ := input["sheet_id"].(string); got != testSheetID {
t.Errorf("sheet_id = %q, want %q", got, testSheetID)
}
})
}
// TestPivotCreate_SchemaValidates exercises the schema-driven
// validator wired into objectCreateInput. The pivot create schema
// doesn't constrain rows/columns/values to be present (the backend
// just creates an empty shell), but it does pin types and enums —
// confirm both kinds of misuse are surfaced as CLI-side errors and
// that schema-conformant input is accepted.
func TestPivotCreate_SchemaValidates(t *testing.T) {
t.Parallel()
t.Run("rejects wrong type for rows", func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
"--url", testURL,
"--properties", `{"rows":"not-an-array"}`,
"--source", "Sheet1!A1:F1000",
"--dry-run",
})
if err == nil {
t.Fatalf("expected schema validator to reject rows=string; stderr=%s", stderr)
}
combined := stderr + err.Error()
if !strings.Contains(combined, "rows") || !strings.Contains(combined, "array") {
t.Errorf("expected error to mention rows/array; got=%s|%v", stderr, err)
}
})
t.Run("rejects out-of-enum summarize_by", func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
"--url", testURL,
"--properties", `{"values":[{"field":"A","summarize_by":"BOGUS"}]}`,
"--source", "Sheet1!A1:F1000",
"--dry-run",
})
if err == nil {
t.Fatalf("expected schema validator to reject summarize_by=BOGUS; stderr=%s", stderr)
}
if !strings.Contains(stderr+err.Error(), "summarize_by") {
t.Errorf("expected error to mention summarize_by; got=%s|%v", stderr, err)
}
})
t.Run("schema-conformant input is accepted", func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, PivotCreate, []string{
"--url", testURL,
"--properties", `{"values":[{"field":"A","summarize_by":"sum"}]}`,
"--source", "Sheet1!A1:F1000",
})
decodeToolInput(t, body, "manage_pivot_table_object")
})
}
// TestObjectCreate_RequiresSheetSelector regresses the non-pivot create
// shortcuts: pivot-create is the only one whose spec sets
// allowEmptySheetSelectorOnCreate=true. Every other *-create must still
// reject empty --sheet-id / --sheet-name (this is the guardrail that
// keeps the change minimally scoped).
func TestObjectCreate_RequiresSheetSelector(t *testing.T) {
t.Parallel()
cases := []struct {
name string
sc common.Shortcut
args []string // omit sheet selector flags on purpose
}{
{"chart", ChartCreate, []string{"--url", testURL, "--properties", `{"type":"line","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`}},
{"cond-format", CondFormatCreate, []string{"--url", testURL, "--properties", `{"attrs":[]}`, "--rule-type", "cellIs", "--ranges", `["A1:A10"]`}},
{"sparkline", SparklineCreate, []string{"--url", testURL, "--properties", `{"sparklines":[]}`}},
{"filter-view", FilterViewCreate, []string{"--url", testURL, "--properties", `{}`, "--range", "A1:F10"}},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
if err == nil {
t.Fatalf("expected CLI to reject empty sheet selector for +%s-create; stderr=%s", tt.name, stderr)
}
combined := stderr + err.Error()
if !strings.Contains(combined, "specify at least one of --sheet-id or --sheet-name") {
t.Errorf("expected 'specify at least one of --sheet-id or --sheet-name'; got=%s|%v", stderr, err)
}
})
}
}
// TestSparklineUpdate_MissingSparklineID confirms the standalone-path
// pre-check fires: +sparkline-update with properties.sparklines[] but no
// per-item sparkline_id must fail CLI-side with a pointer to
// +sparkline-list, before any server call goes out.
func TestSparklineUpdate_MissingSparklineID(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, SparklineUpdate, []string{
"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA",
"--properties", `{"sparklines":[{"source":"Sheet1!A1:A10"}]}`,
})
if err == nil {
t.Fatalf("expected CLI to reject missing sparkline_id; stderr=%s", stderr)
}
combined := stderr + err.Error()
if !strings.Contains(combined, "missing sparkline_id") {
t.Errorf("expected error to mention missing sparkline_id; got=%s|%v", stderr, err)
}
if !strings.Contains(combined, "+sparkline-list") {
t.Errorf("expected error to point at +sparkline-list; got=%s|%v", stderr, err)
}
}
// Note: +float-image-update's image_name / position / size are cobra-required
// (flag-defs.json), so the standalone path is gated by the flag layer — its
// "required flag(s) … not set" wording is framework-owned and intentionally not
// re-asserted here. The CLI-side enforcement that matters is on the
// +batch-update sub-op path (no cobra layer); that is covered by
// TestBatchOp_RejectsBadSubOpInput in batch_op_contract_test.go.
// TestFloatImageCreate_RequiresImageSource guards the asymmetry with update:
// create still mandates one of --image / --image-token / --image-uri.
func TestFloatImageCreate_RequiresImageSource(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, FloatImageCreate, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--image-name", "x.png",
"--position-row", "0", "--position-col", "A",
"--size-width", "10", "--size-height", "10",
})
if err == nil {
t.Fatalf("expected CLI to require an image source on create; stderr=%s", stderr)
}
if combined := stderr + err.Error(); !strings.Contains(combined, "one of --image, --image-token, or --image-uri is required") {
t.Errorf("expected error to require an image source; got=%s|%v", stderr, err)
}
}
// TestObjectDelete_AllHighRisk asserts every delete shortcut blocks
// without --yes (framework-enforced).
func TestObjectDelete_AllHighRisk(t *testing.T) {
t.Parallel()
cases := []struct {
name string
sc common.Shortcut
args []string
}{
{"chart", ChartDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "x"}},
{"pivot", PivotDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--pivot-table-id", "x"}},
{"cond-format", CondFormatDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--rule-id", "x"}},
{"filter", FilterDelete, []string{"--url", testURL, "--sheet-id", testSheetID}},
{"filter-view", FilterViewDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--view-id", "x"}},
{"sparkline", SparklineDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--group-id", "x"}},
{"float-image", FloatImageDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--float-image-id", "x"}},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
if err == nil {
t.Fatalf("expected confirmation_required; stdout=%s stderr=%s", stdout, stderr)
}
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, "confirmation_required") && !strings.Contains(combined, "requires confirmation") {
t.Errorf("expected confirmation gate; got=%s|%s|%v", stdout, stderr, err)
}
})
}
}

View File

@@ -1,157 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── object list shortcuts ────────────────────────────────────────────
//
// Seven object-collection skills each expose a single "list" read shortcut
// that lives next to their CRUD siblings (chart / pivot / cond-format /
// filter / filter-view / sparkline / float-image). All seven share the
// exact same shape — public sheet selector + optional --<id> filter — so
// they're declared via newObjectListShortcut.
//
// +filter-view-list is `cli_status: cli-only`, but the underlying tool
// get_filter_view_objects is in mcp-tools.json and dispatches through the
// same One-OpenAPI endpoint as everything else; no special path needed.
// objectListSpec describes a single list-style read shortcut.
type objectListSpec struct {
command string // CLI command, e.g. "+chart-list"
description string // one-liner for --help
toolName string // MCP tool name, e.g. "get_chart_objects"
// Optional id filter. Empty filterFlag → no filter flag exposed.
filterFlag string // CLI flag name (without leading --), e.g. "chart-id"
filterField string // tool input key, e.g. "chart_id"
}
func newObjectListShortcut(spec objectListSpec) common.Shortcut {
flags := flagsFor(spec.command)
return common.Shortcut{
Service: "sheets",
Command: spec.command,
Description: spec.description,
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flags,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, _, err := resolveSheetSelector(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
return invokeToolDryRun(token, ToolKindRead, spec.toolName, objectListInput(runtime, token, sheetID, sheetName, spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, spec.toolName, objectListInput(runtime, token, sheetID, sheetName, spec))
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
}
func objectListInput(runtime *common.RuntimeContext, token, sheetID, sheetName string, spec objectListSpec) map[string]interface{} {
input := map[string]interface{}{"excel_id": token}
sheetSelectorForToolInput(input, sheetID, sheetName)
if spec.filterFlag != "" {
if v := strings.TrimSpace(runtime.Str(spec.filterFlag)); v != "" {
input[spec.filterField] = v
}
}
return input
}
// ─── shortcut declarations ────────────────────────────────────────────
// ChartList — list charts on a sheet (optionally filtered to one chart_id).
var ChartList = newObjectListShortcut(objectListSpec{
command: "+chart-list",
description: "List charts on a sheet, optionally filtered to a single chart_id.",
toolName: "get_chart_objects",
filterFlag: "chart-id",
filterField: "chart_id",
})
// PivotList — list pivot tables on a sheet.
var PivotList = newObjectListShortcut(objectListSpec{
command: "+pivot-list",
description: "List pivot tables on a sheet, optionally filtered to a single pivot_table_id.",
toolName: "get_pivot_table_objects",
filterFlag: "pivot-table-id",
filterField: "pivot_table_id",
})
// CondFormatList — list conditional format rules. CLI's --rule-id maps to
// the tool's conditional_format_id (CLI uses the shorter common term).
var CondFormatList = newObjectListShortcut(objectListSpec{
command: "+cond-format-list",
description: "List conditional format rules on a sheet, optionally filtered to a single rule.",
toolName: "get_conditional_format_objects",
filterFlag: "rule-id",
filterField: "conditional_format_id",
})
// FilterList — list active sheet-level filters. No id filter because each
// sheet carries at most one filter.
var FilterList = newObjectListShortcut(objectListSpec{
command: "+filter-list",
description: "List active sheet-level filters across the workbook (or one sheet).",
toolName: "get_filter_objects",
})
// FilterViewList — list filter views on a sheet. `cli-only` skill (not
// exposed as MCP tool catalog), but the tool itself is dispatched through
// the same One-OpenAPI endpoint.
var FilterViewList = newObjectListShortcut(objectListSpec{
command: "+filter-view-list",
description: "List filter views on a sheet, optionally filtered to a single view_id.",
toolName: "get_filter_view_objects",
filterFlag: "view-id",
filterField: "view_id",
})
// SparklineList — list sparkline groups on a sheet. The tool also accepts
// a per-sparkline id (`sparkline_id`); CLI exposes the higher-level
// --group-id which is what callers usually care about.
var SparklineList = newObjectListShortcut(objectListSpec{
command: "+sparkline-list",
description: "List sparkline groups on a sheet, optionally filtered by group_id.",
toolName: "get_sparkline_objects",
filterFlag: "group-id",
filterField: "group_id",
})
// FloatImageList — list floating images on a sheet (vs. embedded
// cell-images which live in cell metadata).
var FloatImageList = newObjectListShortcut(objectListSpec{
command: "+float-image-list",
description: "List floating images on a sheet, optionally filtered to a single float_image_id.",
toolName: "get_float_image_objects",
filterFlag: "float-image-id",
filterField: "float_image_id",
})

View File

@@ -1,111 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
// TestObjectListShortcuts_DryRun covers all 7 object-list shortcuts.
// Each spec asserts the tool name + that the optional filter flag maps
// to the right tool field (including the --rule-id → conditional_format_id
// rename).
func TestObjectListShortcuts_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sc common.Shortcut
args []string
toolName string
wantInput map[string]interface{}
}{
{
name: "+chart-list no filter",
sc: ChartList,
args: []string{"--url", testURL, "--sheet-id", testSheetID},
toolName: "get_chart_objects",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
},
},
{
name: "+chart-list with filter",
sc: ChartList,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chartXYZ"},
toolName: "get_chart_objects",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"chart_id": "chartXYZ",
},
},
{
name: "+pivot-list filter",
sc: PivotList,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--pivot-table-id", "ptA"},
toolName: "get_pivot_table_objects",
wantInput: map[string]interface{}{
"pivot_table_id": "ptA",
},
},
{
name: "+cond-format-list --rule-id → conditional_format_id",
sc: CondFormatList,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rule-id", "ruleA"},
toolName: "get_conditional_format_objects",
wantInput: map[string]interface{}{
"conditional_format_id": "ruleA",
},
},
{
name: "+filter-list (no filter flag) by sheet-name",
sc: FilterList,
args: []string{"--url", testURL, "--sheet-name", "Sheet1"},
toolName: "get_filter_objects",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_name": "Sheet1",
},
},
{
name: "+filter-view-list cli-only via callTool",
sc: FilterViewList,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--view-id", "viewABC"},
toolName: "get_filter_view_objects",
wantInput: map[string]interface{}{
"view_id": "viewABC",
},
},
{
name: "+sparkline-list --group-id",
sc: SparklineList,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA"},
toolName: "get_sparkline_objects",
wantInput: map[string]interface{}{
"group_id": "grpA",
},
},
{
name: "+float-image-list",
sc: FloatImageList,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--float-image-id", "imgA"},
toolName: "get_float_image_objects",
wantInput: map[string]interface{}{
"float_image_id": "imgA",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, tt.sc, tt.args)
got := decodeToolInput(t, body, tt.toolName)
assertInputEquals(t, got, tt.wantInput)
})
}
}

View File

@@ -1,665 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"errors"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_range_operations ──────────────────────────────────────
//
// Four tools, nine shortcuts:
//
// - clear_cell_range → +cells-clear (high-risk-write)
// - merge_cells → +cells-merge / +cells-unmerge
// - resize_range → +rows-resize / +cols-resize
// - transform_range → +range-move / +range-copy / +range-fill / +range-sort
//
// +rows-resize / +cols-resize are grouped under "工作表" for CLI discoverability
// even though the backing tool lives in this skill.
// CellsClear wraps clear_cell_range.
//
// CLI's --scope vocabulary (content / formats / all) is normalized to the
// tool's clear_type vocabulary (contents / formats / all) — the spec's
// singular/plural mismatch is intentionally absorbed here.
var CellsClear = common.Shortcut{
Service: "sheets",
Command: "+cells-clear",
Description: "Clear cell content, formats, or both within a range (irreversible).",
Risk: "high-risk-write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-clear"),
Validate: validateViaInput(cellsClearInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := cellsClearInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "clear_cell_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := cellsClearInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "clear_cell_range", input)
if err != nil {
return annotateEmbeddedBlockClearErr(err)
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"high-risk-write — always preview with --dry-run; clear is not undoable.",
"Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.",
},
}
func cellsClearInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.FlagErrorf("--range is required")
}
input := map[string]interface{}{
"excel_id": token,
"range": strings.TrimSpace(runtime.Str("range")),
"clear_type": normalizeClearType(runtime.Str("scope")),
}
sheetSelectorForToolInput(input, sheetID, sheetName)
return input, nil
}
// normalizeClearType maps the CLI --scope vocabulary (content / formats / all)
// to the clear_cell_range tool's clear_type vocabulary (contents / formats /
// all). The content↔contents singular/plural mismatch is absorbed here so both
// +cells-clear and the +cells-batch-clear fan-out stay in lockstep.
func normalizeClearType(scope string) string {
switch scope {
case "formats", "all":
return scope
default: // "content" or unset
return "contents"
}
}
// annotateEmbeddedBlockClearErr augments the backend's "embedded block" clear
// failure with the concrete fix. clear_cell_range only clears cell values /
// formats — it cannot delete an embedded object (pivot table / chart) that
// overlaps the range, which is what the backend's "can not find embedded block"
// actually means. Trajectories burned dozens of commands trying to recover a
// pivot-occupied A1 with cells-clear; point the agent at the object's own
// delete command instead. Non-matching errors pass through untouched.
func annotateEmbeddedBlockClearErr(err error) error {
var ee *output.ExitError
if !errors.As(err, &ee) || ee.Detail == nil {
return err
}
if !strings.Contains(strings.ToLower(ee.Detail.Message), "embedded block") {
return err
}
const hint = "the range overlaps an embedded object (pivot table / chart); " +
"cells-clear only clears cell values/formats and cannot delete it — " +
"delete the object with its own command (+pivot-delete / +chart-delete; find the id via +pivot-list / +chart-list)"
if ee.Detail.Hint == "" {
ee.Detail.Hint = hint
} else {
ee.Detail.Hint += "; " + hint
}
return ee
}
// CellsMerge / CellsUnmerge share the merge_cells tool, dispatched by the
// `operation` enum. --merge-type applies to merge only and maps to tool
// field merge_type (`all` / `rows` / `columns`).
var CellsMerge = newMergeShortcut(
"+cells-merge", "Merge cells in a range.", "merge", true,
)
var CellsUnmerge = newMergeShortcut(
"+cells-unmerge", "Unmerge cells in a range.", "unmerge", false,
)
func newMergeShortcut(command, desc, op string, withMergeType bool) common.Shortcut {
flags := flagsFor(command)
return common.Shortcut{
Service: "sheets",
Command: command,
Description: desc,
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flags,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID := strings.TrimSpace(runtime.Str("sheet-id"))
sheetName := strings.TrimSpace(runtime.Str("sheet-name"))
_, err = mergeInput(runtime, token, sheetID, sheetName, op, withMergeType)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := mergeInput(runtime, token, sheetID, sheetName, op, withMergeType)
return invokeToolDryRun(token, ToolKindWrite, "merge_cells", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := mergeInput(runtime, token, sheetID, sheetName, op, withMergeType)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "merge_cells", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
}
func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMergeType bool) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.FlagErrorf("--range is required")
}
input := map[string]interface{}{
"excel_id": token,
"range": strings.TrimSpace(runtime.Str("range")),
"operation": op,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if withMergeType {
if mt := runtime.Str("merge-type"); mt != "" && mt != "all" {
input["merge_type"] = mt
} else {
input["merge_type"] = "all"
}
}
return input, nil
}
// resize_range exposes two CLI shortcuts:
//
// +rows-resize / +cols-resize — set row heights / column widths. --type
// enum (pixel / standard / [auto]) controls how: --type pixel needs --size,
// --type standard restores the sheet default, --type auto auto-fits row
// heights (rows only). --range is an A1 closed range ("2:10" / "5" rows or
// "A:E" / "C" columns); single-element form is expanded to "N:N" before
// send because resize_range rejects bare single-element ranges.
//
// Wire shape: resize_height / resize_width carries { type, value? }, e.g.
// { "type": "pixel", "value": 30 } or { "type": "standard" }.
// RowsResize wraps resize_range for row heights. --type auto enables
// auto-fit (rows only); --type pixel requires --size.
var RowsResize = common.Shortcut{
Service: "sheets",
Command: "+rows-resize",
Description: "Resize rows by pixel / standard / auto (--type pixel needs --size; --range is 1-based A1 like \"2:10\" or \"5\").",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+rows-resize"),
Validate: validateViaResize("row"),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := resizeInput(runtime, token, sheetID, sheetName, "row")
return invokeToolDryRun(token, ToolKindWrite, "resize_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := resizeInput(runtime, token, sheetID, sheetName, "row")
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// ColsResize wraps resize_range for column widths. Column widths do not
// support auto-fit — --type only accepts pixel / standard.
var ColsResize = common.Shortcut{
Service: "sheets",
Command: "+cols-resize",
Description: "Resize columns by pixel / standard (--type pixel needs --size; --range is column letters like \"A:E\" or \"C\"; no auto for cols).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cols-resize"),
Validate: validateViaResize("column"),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := resizeInput(runtime, token, sheetID, sheetName, "column")
return invokeToolDryRun(token, ToolKindWrite, "resize_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := resizeInput(runtime, token, sheetID, sheetName, "column")
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// validateViaResize wires the standalone Validate to resizeInput so both
// paths (standalone + batch sub-op) emit the same error for missing --type,
// malformed --range, or --type auto on columns.
func validateViaResize(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) error {
return func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID := strings.TrimSpace(runtime.Str("sheet-id"))
sheetName := strings.TrimSpace(runtime.Str("sheet-name"))
_, err = resizeInput(runtime, token, sheetID, sheetName, dimension)
return err
}
}
// autoSuffix appends " / auto" to the enum hint for rows.
func autoSuffix(dimension string) string {
if dimension == "row" {
return " / auto"
}
return ""
}
// commandForDimension returns the shortcut command name a given dimension
// belongs to; used in error messages so users see "+rows-resize" / "+cols-resize"
// instead of the internal "row" / "column" tag.
func commandForDimension(dimension string) string {
if dimension == "row" {
return "+rows-resize"
}
return "+cols-resize"
}
// resizeInput builds the resize_range tool input. dimension is "row" /
// "column" (selected by the calling shortcut); --range must match that
// dimension (row → digits like "2:10" / "5"; column → letters like "A:E" /
// "C"). Single-element form is expanded to "N:N" because resize_range
// rejects bare single-element ranges.
func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if !runtime.Changed("range") {
return nil, common.FlagErrorf("--range is required")
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
parsedDim, _, _, err := parseA1Range(rangeStr)
if err != nil {
return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err)
}
if parsedDim != dimension {
want := "row numbers (e.g. \"2:10\")"
if dimension == "column" {
want = "column letters (e.g. \"A:E\")"
}
return nil, common.FlagErrorf("--range %q is a %s range; %s expects %s", rangeStr, parsedDim, commandForDimension(dimension), want)
}
if !strings.Contains(rangeStr, ":") {
rangeStr = rangeStr + ":" + rangeStr
}
typ := strings.TrimSpace(runtime.Str("type"))
if typ == "" {
return nil, common.FlagErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension))
}
if dimension == "column" && typ == "auto" {
return nil, common.FlagErrorf("--type auto is rows-only (column widths do not support auto-fit); use +rows-resize")
}
hasSize := runtime.Changed("size") && runtime.Int("size") > 0
if typ == "pixel" && !hasSize {
return nil, common.FlagErrorf("--type pixel requires --size <px>")
}
if typ != "pixel" && hasSize {
return nil, common.FlagErrorf("--size is only valid with --type pixel")
}
input := map[string]interface{}{
"excel_id": token,
"range": rangeStr,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
sizeBlock := map[string]interface{}{"type": typ}
if typ == "pixel" {
sizeBlock["value"] = runtime.Int("size")
}
if dimension == "row" {
input["resize_height"] = sizeBlock
} else {
input["resize_width"] = sizeBlock
}
return input, nil
}
// ─── transform_range (4 shortcuts) ────────────────────────────────────
//
// move / copy take --source-range + --target-range (+ optional cross-sheet
// target). fill takes --source-range + --target-range + --series-type. sort
// takes --range + --sort-keys + --has-header.
// RangeMove cuts data from --source-range and pastes at --target-range,
// optionally on another sheet.
var RangeMove = common.Shortcut{
Service: "sheets",
Command: "+range-move",
Description: "Cut a range and paste it at a new location (optionally cross-sheet).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+range-move"),
Validate: validateRangeMoveOrCopy("move", false),
DryRun: transformDryRunFn("move", false, false),
Execute: transformExecuteFn("move", false, false),
}
// RangeCopy duplicates a range to a new location with optional paste-type
// filter (values / formulas / formats / all).
var RangeCopy = common.Shortcut{
Service: "sheets",
Command: "+range-copy",
Description: "Copy a range to a new location (--paste-type controls what is copied).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+range-copy"),
Validate: validateRangeMoveOrCopy("copy", true),
DryRun: transformDryRunFn("copy", true, false),
Execute: transformExecuteFn("copy", true, false),
}
// RangeFill performs autofill from a template range into a target range.
// --series-type is a 5-value CLI vocabulary; the tool only distinguishes
// `copyCells` from `fillSeries`. The mapping is documented in
// fillSeriesToToolType.
var RangeFill = common.Shortcut{
Service: "sheets",
Command: "+range-fill",
Description: "Autofill a target range from a source template (copy / linear / growth / date series).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+range-fill"),
Validate: validateViaInput(rangeFillInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := rangeFillInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "transform_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := rangeFillInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "transform_range", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// RangeSort sorts rows within a range by one or more columns.
var RangeSort = common.Shortcut{
Service: "sheets",
Command: "+range-sort",
Description: "Sort rows within a range by one or more columns.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+range-sort"),
Validate: validateViaInput(rangeSortInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := rangeSortInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "transform_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := rangeSortInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "transform_range", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// ─── transform_range helpers ──────────────────────────────────────────
// validateRangeMoveOrCopy wires the standalone Validate to transformMoveCopyInput
// so missing --source-range / --target-range fire the same friendly error on
// the batch sub-op path.
func validateRangeMoveOrCopy(op string, withPasteType bool) func(ctx context.Context, runtime *common.RuntimeContext) error {
return func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID := strings.TrimSpace(runtime.Str("sheet-id"))
sheetName := strings.TrimSpace(runtime.Str("sheet-name"))
_, err = transformMoveCopyInput(runtime, token, sheetID, sheetName, op, withPasteType)
return err
}
}
func transformDryRunFn(op string, withPasteType, _ bool) func(context.Context, *common.RuntimeContext) *common.DryRunAPI {
return func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := transformMoveCopyInput(runtime, token, sheetID, sheetName, op, withPasteType)
return invokeToolDryRun(token, ToolKindWrite, "transform_range", input)
}
}
func transformExecuteFn(op string, withPasteType, _ bool) func(context.Context, *common.RuntimeContext) error {
return func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := transformMoveCopyInput(runtime, token, sheetID, sheetName, op, withPasteType)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "transform_range", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
}
}
func transformMoveCopyInput(runtime flagView, token, sheetID, sheetName, op string, withPasteType bool) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if strings.TrimSpace(runtime.Str("source-range")) == "" {
return nil, common.FlagErrorf("--source-range is required")
}
if strings.TrimSpace(runtime.Str("target-range")) == "" {
return nil, common.FlagErrorf("--target-range is required")
}
input := map[string]interface{}{
"excel_id": token,
"operation": op,
"range": strings.TrimSpace(runtime.Str("source-range")),
"destination_range": strings.TrimSpace(runtime.Str("target-range")),
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if tgt := strings.TrimSpace(runtime.Str("target-sheet-id")); tgt != "" {
input["destination_sheet_id"] = tgt
}
if withPasteType {
if pt := runtime.Str("paste-type"); pt != "" && pt != "all" {
input["paste_type"] = pasteTypeToTool(pt)
}
}
return input, nil
}
// pasteTypeToTool maps the CLI vocabulary (values / formulas / formats / all)
// to the tool's paste_type field (all / value_only / formula_only / format_only).
func pasteTypeToTool(pt string) string {
switch pt {
case "values":
return "value_only"
case "formulas":
return "formula_only"
case "formats":
return "format_only"
}
return "all"
}
func rangeFillInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if strings.TrimSpace(runtime.Str("source-range")) == "" {
return nil, common.FlagErrorf("--source-range is required")
}
if strings.TrimSpace(runtime.Str("target-range")) == "" {
return nil, common.FlagErrorf("--target-range is required")
}
input := map[string]interface{}{
"excel_id": token,
"operation": "fill",
"range": strings.TrimSpace(runtime.Str("source-range")),
"destination_range": strings.TrimSpace(runtime.Str("target-range")),
"fill_type": fillSeriesToToolType(runtime.Str("series-type")),
}
sheetSelectorForToolInput(input, sheetID, sheetName)
return input, nil
}
// fillSeriesToToolType maps the CLI series vocabulary to the tool's fill_type.
// The tool only distinguishes copy vs series; the CLI's series flavor (linear /
// growth / date / auto) all collapse to fillSeries — the actual progression is
// inferred by the server from the source cells.
func fillSeriesToToolType(seriesType string) string {
if seriesType == "copy" {
return "copyCells"
}
return "fillSeries"
}
func rangeSortInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, common.FlagErrorf("--range is required")
}
// requireJSONArray runs the embedded JSON Schema for --sort-keys
// via parseJSONFlag → validateParsedJSONFlag, so each item is
// already pinned to {column: string, ascending: bool} with the
// failing index reported. No per-item hand-written guard needed.
keys, err := requireJSONArray(runtime, "sort-keys")
if err != nil {
return nil, err
}
input := map[string]interface{}{
"excel_id": token,
"operation": "sort",
"range": strings.TrimSpace(runtime.Str("range")),
"sort_conditions": keys,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if runtime.Bool("has-header") {
input["has_header"] = true
}
return input, nil
}

View File

@@ -1,360 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
func TestAnnotateEmbeddedBlockClearErr(t *testing.T) {
t.Parallel()
t.Run("adds pivot-delete hint on embedded-block error", func(t *testing.T) {
in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{
Type: "api",
Message: `tool "clear_cell_range" failed: [500] can not find embedded block`,
}}
var ee *output.ExitError
if !errors.As(annotateEmbeddedBlockClearErr(in), &ee) || ee.Detail == nil {
t.Fatal("expected ExitError with detail")
}
if !strings.Contains(ee.Detail.Hint, "+pivot-delete") {
t.Errorf("hint should point at +pivot-delete, got %q", ee.Detail.Hint)
}
})
t.Run("appends to existing hint", func(t *testing.T) {
in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{
Message: "embedded block missing", Hint: "preexisting",
}}
out := annotateEmbeddedBlockClearErr(in).(*output.ExitError)
if !strings.HasPrefix(out.Detail.Hint, "preexisting; ") {
t.Errorf("existing hint should be preserved and appended, got %q", out.Detail.Hint)
}
})
t.Run("passes through unrelated ExitError untouched", func(t *testing.T) {
in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{Message: "some other failure"}}
out := annotateEmbeddedBlockClearErr(in).(*output.ExitError)
if out.Detail.Hint != "" {
t.Errorf("unrelated error should not gain a hint, got %q", out.Detail.Hint)
}
})
t.Run("passes through non-ExitError untouched", func(t *testing.T) {
in := errors.New("can not find embedded block")
if out := annotateEmbeddedBlockClearErr(in); out != in {
t.Error("plain (non-ExitError) error should be returned as-is")
}
})
}
func TestRangeOperationsShortcuts_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sc common.Shortcut
args []string
toolName string
wantInput map[string]interface{}
}{
{
name: "+cells-clear scope=content → clear_type=contents",
sc: CellsClear,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C5", "--scope", "content"},
toolName: "clear_cell_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "A1:C5",
"clear_type": "contents",
},
},
{
name: "+cells-clear scope=all passthrough",
sc: CellsClear,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C5", "--scope", "all"},
toolName: "clear_cell_range",
wantInput: map[string]interface{}{
"clear_type": "all",
},
},
{
name: "+cells-merge with merge-type",
sc: CellsMerge,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--merge-type", "rows"},
toolName: "merge_cells",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "A1:B2",
"operation": "merge",
"merge_type": "rows",
},
},
{
name: "+cells-unmerge (no merge-type flag)",
sc: CellsUnmerge,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2"},
toolName: "merge_cells",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "A1:B2",
"operation": "unmerge",
},
},
{
name: "+rows-resize --range 1:5 pixel 200",
sc: RowsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel", "--size", "200"},
toolName: "resize_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "1:5",
"resize_height": map[string]interface{}{
"type": "pixel",
"value": float64(200),
},
},
},
{
name: "+rows-resize single row \"1\" expands to \"1:1\"",
sc: RowsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1", "--type", "auto"},
toolName: "resize_range",
wantInput: map[string]interface{}{
"range": "1:1",
"resize_height": map[string]interface{}{"type": "auto"},
},
},
{
name: "+cols-resize --range B:D standard",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "B:D", "--type", "standard"},
toolName: "resize_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "B:D",
"resize_width": map[string]interface{}{
"type": "standard",
},
},
},
{
name: "+cols-resize --range A:C pixel 120",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel", "--size", "120"},
toolName: "resize_range",
wantInput: map[string]interface{}{
"range": "A:C",
"resize_width": map[string]interface{}{
"type": "pixel",
"value": float64(120),
},
},
},
{
name: "+cols-resize single column \"C\" expands to \"C:C\"",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "C", "--type", "standard"},
toolName: "resize_range",
wantInput: map[string]interface{}{
"range": "C:C",
"resize_width": map[string]interface{}{"type": "standard"},
},
},
{
name: "+range-move cross-sheet",
sc: RangeMove,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:C5", "--target-range", "D1", "--target-sheet-id", testSheetID2},
toolName: "transform_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "move",
"range": "A1:C5",
"destination_range": "D1",
"destination_sheet_id": testSheetID2,
},
},
{
name: "+range-copy paste-type values → value_only",
sc: RangeCopy,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:C5", "--target-range", "E1", "--paste-type", "values"},
toolName: "transform_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "copy",
"range": "A1:C5",
"destination_range": "E1",
"paste_type": "value_only",
},
},
{
name: "+range-copy paste-type all → field omitted",
sc: RangeCopy,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:C5", "--target-range", "E1"},
toolName: "transform_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "copy",
"range": "A1:C5",
"destination_range": "E1",
},
},
{
name: "+range-fill series=copy → copyCells",
sc: RangeFill,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:A3", "--target-range", "A4:A10", "--series-type", "copy"},
toolName: "transform_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "fill",
"range": "A1:A3",
"destination_range": "A4:A10",
"fill_type": "copyCells",
},
},
{
name: "+range-fill series=linear → fillSeries",
sc: RangeFill,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:A3", "--target-range", "A4:A10", "--series-type", "linear"},
toolName: "transform_range",
wantInput: map[string]interface{}{
"fill_type": "fillSeries",
},
},
{
name: "+range-sort multi-key with header",
sc: RangeSort,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:E100", "--has-header", "--sort-keys", `[{"column":"B","ascending":true},{"column":"D","ascending":false}]`},
toolName: "transform_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "sort",
"range": "A1:E100",
"has_header": true,
"sort_conditions": []interface{}{
map[string]interface{}{"column": "B", "ascending": true},
map[string]interface{}{"column": "D", "ascending": false},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, tt.sc, tt.args)
got := decodeToolInput(t, body, tt.toolName)
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestRangeSort_RejectsMalformedKeys verifies the schema-driven check
// that each --sort-keys entry has both `column` (string) and
// `ascending` (bool). The schema validator (loaded from
// data/flag-schemas.json) reports the offending JSON path; previously
// the CLI passed any JSON through and the server bounced with a terse
// "required property X missing" that didn't name the bad entry.
func TestRangeSort_RejectsMalformedKeys(t *testing.T) {
t.Parallel()
cases := []struct {
name string
keys string
want string
}{
{"missing column", `[{"ascending":true}]`, `required property "column" is missing at [0]`},
{"missing ascending", `[{"column":"B"}]`, `required property "ascending" is missing at [0]`},
{"old vocab col/order", `[{"col":"B","order":"asc"}]`, `required property "column" is missing at [0]`},
{"non-object item", `["B"]`, `[0]: expected type "object"`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, RangeSort, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:E10", "--sort-keys", c.keys, "--dry-run",
})
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), c.want) {
t.Errorf("want substring %q in error; got stdout=%s stderr=%s err=%v", c.want, stdout, stderr, err)
}
})
}
}
func TestResize_TypeAndSizeGuards(t *testing.T) {
t.Parallel()
cases := []struct {
name string
sc common.Shortcut
args []string
want string
}{
{
name: "+rows-resize --type pixel without --size",
sc: RowsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel"},
want: "--type pixel requires --size",
},
{
name: "+rows-resize --type standard with --size",
sc: RowsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "standard", "--size", "30"},
want: "--size is only valid with --type pixel",
},
{
name: "+cols-resize rejects --type auto",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "auto"},
want: "auto", // cobra Enum gate kicks first with "valid values are: pixel, standard"
},
{
name: "+rows-resize given column range",
sc: RowsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "standard"},
want: "+rows-resize expects row numbers",
},
{
name: "+cols-resize given row range",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "standard"},
want: "+cols-resize expects column letters",
},
{
name: "+rows-resize end < start",
sc: RowsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "5:3", "--type", "standard"},
want: "end position is before start",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), tt.want) {
t.Errorf("expected %q; got=%s|%s|%v", tt.want, stdout, stderr, err)
}
})
}
}

View File

@@ -1,298 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_read_data ─────────────────────────────────────────────
//
// Wraps:
// - get_cell_ranges (powers +cells-get and +dropdown-get)
// - get_range_as_csv (powers +csv-get)
//
// The sandbox tool (export_sheet_to_sandbox) is Sheet-Tool-only and has no
// CLI surface here.
// unboundedReadLimit is pinned into the tool's cell_limit / max_rows so that
// --max-chars is the single effective read cap. The underlying tools default
// those two to smaller values; without an explicit high value they could
// truncate before max_chars. The CLI no longer exposes --cell-limit / --max-rows
// (only --max-chars), so we pass this sentinel to neutralize the tool defaults.
// Large enough to never bind on any real sheet.
const unboundedReadLimit = 1_000_000_000
// CellsGet wraps get_cell_ranges: read multiple A1 ranges and return per-cell
// values, formulas, styles, and other metadata as requested via --include.
var CellsGet = common.Shortcut{
Service: "sheets",
Command: "+cells-get",
Description: "Read one or more cell ranges with values, formulas, and optional styles / comments / data validation.",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-get"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
if _, _, err := resolveSheetSelector(runtime); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return common.FlagErrorf("--range is required")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", cellsGetInput(runtime, token, sheetID, sheetName))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", cellsGetInput(runtime, token, sheetID, sheetName))
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
input := map[string]interface{}{
"excel_id": token,
"ranges": []string{strings.TrimSpace(runtime.Str("range"))},
}
sheetSelectorForToolInput(input, sheetID, sheetName)
applyIncludeToCellsGet(input, runtime.StrSlice("include"))
if runtime.Bool("skip-hidden") {
input["skip_hidden"] = true
}
// --cell-limit was removed from the CLI surface; --max-chars is the single
// read cap. Pin cell_limit very high so the tool's own default never binds
// before max_chars.
input["cell_limit"] = unboundedReadLimit
if n := runtime.Int("max-chars"); n > 0 {
input["max_chars"] = n
}
return input
}
// applyIncludeToCellsGet maps the fine-grained --include vocabulary to the
// tool's two coarse switches:
//
// - include_styles (bool) — toggled by "style" presence
// - value_render_option (enum) — "formula" → formula; otherwise omitted
//
// "value", "comment", and "data_validation" are always returned by the tool
// per the schema; they have no dedicated knob today but are accepted in
// --include for forward-compat with finer-grained server support.
func applyIncludeToCellsGet(input map[string]interface{}, include []string) {
if len(include) == 0 {
return
}
want := map[string]bool{}
for _, v := range include {
want[v] = true
}
if want["style"] {
input["include_styles"] = true
} else {
input["include_styles"] = false
}
if want["formula"] {
input["value_render_option"] = "formula"
}
}
// CsvGet wraps get_range_as_csv: pull one range as RFC 4180 CSV with optional
// [row=N] line prefix for easy row-number lookup.
var CsvGet = common.Shortcut{
Service: "sheets",
Command: "+csv-get",
Description: "Read a range as CSV (with [row=N] line prefix by default).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+csv-get"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
if _, _, err := resolveSheetSelector(runtime); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return common.FlagErrorf("--range is required")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
return invokeToolDryRun(token, ToolKindRead, "get_range_as_csv", csvGetInput(runtime, token, sheetID, sheetName))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_range_as_csv", csvGetInput(runtime, token, sheetID, sheetName))
if err != nil {
return err
}
if !runtime.Bool("include-row-prefix") {
out = stripRowPrefixFromCsvOutput(out)
}
runtime.Out(out, nil)
return nil
},
}
func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
input := map[string]interface{}{"excel_id": token}
sheetSelectorForToolInput(input, sheetID, sheetName)
if r := strings.TrimSpace(runtime.Str("range")); r != "" {
input["range"] = r
}
if runtime.Bool("skip-hidden") {
input["skip_hidden"] = true
}
// --max-rows was removed from the CLI surface; --max-chars is the single
// read cap. Pin max_rows very high so the tool's own default never binds
// before max_chars.
input["max_rows"] = unboundedReadLimit
if n := runtime.Int("max-chars"); n > 0 {
input["max_chars"] = n
}
return input
}
// stripRowPrefixFromCsvOutput removes "[row=N]" line prefixes from the tool's
// annotated_csv field. Operates client-side because the tool only emits the
// annotated form.
func stripRowPrefixFromCsvOutput(out interface{}) interface{} {
m, ok := out.(map[string]interface{})
if !ok {
return out
}
csv, ok := m["annotated_csv"].(string)
if !ok {
return out
}
lines := strings.Split(csv, "\n")
for i, line := range lines {
if idx := strings.Index(line, "]"); idx >= 0 && strings.HasPrefix(line, "[row=") {
rest := line[idx+1:]
lines[i] = strings.TrimPrefix(rest, ",")
}
}
m["annotated_csv"] = strings.Join(lines, "\n")
return m
}
// a1EndRow extracts the ending row number from an A1 range, e.g. "A1:N51" → 51,
// "Sheet1!B2:D9" → 9, "C5" → 5. Returns 0 when no row number is present.
func a1EndRow(rng string) int {
rng = strings.TrimSpace(rng)
if i := strings.LastIndex(rng, "!"); i >= 0 {
rng = rng[i+1:]
}
if i := strings.LastIndex(rng, ":"); i >= 0 {
rng = rng[i+1:]
}
var digits strings.Builder
for _, c := range rng {
if c >= '0' && c <= '9' {
digits.WriteRune(c)
}
}
if digits.Len() == 0 {
return 0
}
n, _ := strconv.Atoi(digits.String())
return n
}
// DropdownGet wraps get_cell_ranges scoped to data_validation: read the
// dropdown configuration on a range. Aligned with its sibling +cells-get
// — sheet selection is via --sheet-id / --sheet-name (XOR), and --range
// is a bare A1 reference. The earlier "must include a sheet prefix"
// shape was the odd one out among the get_cell_ranges wrappers and made
// callers treat the prefix as either name or id; folding it into the
// canonical --sheet-id selector removes that ambiguity.
var DropdownGet = common.Shortcut{
Service: "sheets",
Command: "+dropdown-get",
Description: "Read the dropdown / data-validation configuration on a range.",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dropdown-get"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
if _, _, err := resolveSheetSelector(runtime); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return common.FlagErrorf("--range is required")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token, sheetID, sheetName))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token, sheetID, sheetName))
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
func dropdownGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
input := map[string]interface{}{
"excel_id": token,
"ranges": []string{strings.TrimSpace(runtime.Str("range"))},
"include_styles": false,
"value_render_option": "formatted_value",
}
sheetSelectorForToolInput(input, sheetID, sheetName)
return input
}

View File

@@ -1,167 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
func TestReadDataShortcuts_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sc common.Shortcut
args []string
toolName string
wantInput map[string]interface{}
}{
{
name: "+cells-get single range + include=style,formula",
sc: CellsGet,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--include", "style,formula"},
toolName: "get_cell_ranges",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"ranges": []interface{}{"A1:B2"},
"include_styles": true,
"value_render_option": "formula",
"cell_limit": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap
},
},
{
// Canonical form: --sheet-id + bare --range. Aligned with
// +cells-get / +csv-get; before the e2e BUG-019 fix this
// shortcut was the odd one out (range-prefix required).
name: "+dropdown-get with --sheet-id",
sc: DropdownGet,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "C2:C6"},
toolName: "get_cell_ranges",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"ranges": []interface{}{"C2:C6"},
"include_styles": false,
"value_render_option": "formatted_value",
},
},
{
name: "+dropdown-get with --sheet-name",
sc: DropdownGet,
args: []string{"--url", testURL, "--sheet-name", "Sheet1", "--range", "C2:C6"},
toolName: "get_cell_ranges",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_name": "Sheet1",
"ranges": []interface{}{"C2:C6"},
"include_styles": false,
"value_render_option": "formatted_value",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, tt.sc, tt.args)
got := decodeToolInput(t, body, tt.toolName)
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestDropdownGet_RequiresSheetSelector locks the +cells-get-style
// selector contract: at least one of --sheet-id / --sheet-name must be
// supplied. Before BUG-019 fix this shortcut required a "Sheet!A1"
// prefix inside --range instead; the canonical selector pair is what
// every other get_cell_ranges wrapper uses.
func TestDropdownGet_RequiresSheetSelector(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, DropdownGet, []string{
"--url", testURL, "--range", "A2:A100", "--dry-run",
})
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, "sheet-id") && !strings.Contains(combined, "sheet-name") {
t.Errorf("expected --sheet-id/--sheet-name guard; got=%s|%s|%v", stdout, stderr, err)
}
}
// TestReadData_RequiresRange covers the trim-based --range guard on the
// single-range readers (--range "" slips past cobra's MarkFlagRequired but
// must still be rejected by Validate).
func TestReadData_RequiresRange(t *testing.T) {
t.Parallel()
cases := []struct {
name string
sc common.Shortcut
}{
{"+cells-get", CellsGet},
{"+csv-get", CsvGet},
{"+dropdown-get", DropdownGet},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, c.sc, []string{
"--url", testURL, "--sheet-id", testSheetID, "--range", " ", "--dry-run",
})
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), "--range is required") {
t.Errorf("expected --range guard; got=%s|%s|%v", stdout, stderr, err)
}
})
}
}
// TestInfoTypeFromInclude exercises the fine-grained → coarse mapping
// directly (white-box).
func TestInfoTypeFromInclude(t *testing.T) {
t.Parallel()
// Caller (sheetInfoInput) skips infoTypeFromInclude when len(include)==0,
// so the helper only ever sees non-empty input.
cases := []struct {
include []string
want string
}{
{[]string{"row_heights"}, "row_heights_column_widths"},
{[]string{"row_heights", "col_widths"}, "row_heights_column_widths"},
{[]string{"hidden_rows", "hidden_cols"}, "hidden_infos"},
{[]string{"groups"}, "group_infos"},
{[]string{"merges"}, "merged_cells_infos"},
{[]string{"row_heights", "merges"}, "all"}, // mixed
{[]string{"frozen"}, "all"}, // frozen alone falls back to all
{[]string{"unknown"}, "all"}, // unknown → all
}
for _, c := range cases {
if got := infoTypeFromInclude(c.include); got != c.want {
t.Errorf("infoTypeFromInclude(%v) = %q, want %q", c.include, got, c.want)
}
}
}
// TestCsvGet_StripRowPrefix verifies the client-side post-process for
// --include-row-prefix=false.
func TestCsvGet_StripRowPrefix(t *testing.T) {
t.Parallel()
in := map[string]interface{}{
"annotated_csv": "[row=1] a,b,c\n[row=2] d,e,f",
"other": "untouched",
}
out := stripRowPrefixFromCsvOutput(in).(map[string]interface{})
csv := out["annotated_csv"].(string)
if csv != " a,b,c\n d,e,f" {
t.Errorf("annotated_csv = %q, want stripped prefix", csv)
}
if out["other"] != "untouched" {
t.Errorf("other field corrupted: %v", out["other"])
}
}

View File

@@ -1,172 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_search_replace ────────────────────────────────────────
//
// Wraps search_data (read) and replace_data (write). Both tools take an
// `options` sub-object; the CLI flattens its common booleans
// (--match-case / --match-entire-cell / --regex / --include-formulas) into
// independent flags per the铁律.
// CellsSearch wraps search_data: find cell coordinates matching --find,
// with optional case / regex / whole-cell / formula-text controls.
var CellsSearch = common.Shortcut{
Service: "sheets",
Command: "+cells-search",
Description: "Find cells matching --find in a spreadsheet (case / regex / whole-cell / formula-text controls).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-search"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
if _, _, err := resolveSheetSelector(runtime); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("find")) == "" {
return common.FlagErrorf("--find is required")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
return invokeToolDryRun(token, ToolKindRead, "search_data", searchInput(runtime, token, sheetID, sheetName))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "search_data", searchInput(runtime, token, sheetID, sheetName))
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
func searchInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
input := map[string]interface{}{
"excel_id": token,
"search_term": runtime.Str("find"),
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if r := strings.TrimSpace(runtime.Str("range")); r != "" {
input["range"] = r
}
if runtime.Changed("offset") && runtime.Int("offset") > 0 {
input["offset"] = runtime.Int("offset")
}
if opts := searchReplaceOptions(runtime); len(opts) > 0 {
input["options"] = opts
}
if n := runtime.Int("max-matches"); n > 0 {
input["max_matches"] = n
}
return input
}
// searchReplaceOptions packs the four shared boolean flags into the tool's
// `options` sub-object. Empty result → caller should omit the field.
func searchReplaceOptions(runtime flagView) map[string]interface{} {
opts := map[string]interface{}{}
if runtime.Bool("match-case") {
opts["match_case"] = true
}
if runtime.Bool("match-entire-cell") {
opts["match_entire_cell"] = true
}
if runtime.Bool("regex") {
opts["use_regex"] = true
}
if runtime.Bool("include-formulas") {
opts["match_formulas"] = true
}
return opts
}
// CellsReplace wraps replace_data: find and replace text across a
// spreadsheet, with the same option controls as +cells-search.
var CellsReplace = common.Shortcut{
Service: "sheets",
Command: "+cells-replace",
Description: "Find and replace text in a spreadsheet (case / regex / whole-cell / formula-text controls).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-replace"),
Validate: validateViaInput(replaceInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := replaceInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "replace_data", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := replaceInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "replace_data", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Always preview with --dry-run before running — replace can mutate every matching cell across the sheet.",
},
}
func replaceInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if strings.TrimSpace(runtime.Str("find")) == "" {
return nil, common.FlagErrorf("--find is required")
}
if !runtime.Changed("replacement") {
return nil, common.FlagErrorf("--replacement is required (pass an empty string to delete matches)")
}
input := map[string]interface{}{
"excel_id": token,
"search_term": runtime.Str("find"),
"replace_term": runtime.Str("replacement"),
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if r := strings.TrimSpace(runtime.Str("range")); r != "" {
input["range"] = r
}
if opts := searchReplaceOptions(runtime); len(opts) > 0 {
input["options"] = opts
}
return input, nil
}

View File

@@ -1,102 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
func TestSearchReplaceShortcuts_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sc common.Shortcut
args []string
toolName string
wantInput map[string]interface{}
wantOptions map[string]interface{}
}{
{
name: "+cells-search regex + match-case",
sc: CellsSearch,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--regex", "--match-case"},
toolName: "search_data",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"search_term": "foo",
},
wantOptions: map[string]interface{}{
"match_case": true,
"use_regex": true,
},
},
{
name: "+cells-search all four options",
sc: CellsSearch,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--find", "x", "--match-case", "--match-entire-cell", "--regex", "--include-formulas"},
toolName: "search_data",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"search_term": "x",
},
wantOptions: map[string]interface{}{
"match_case": true,
"match_entire_cell": true,
"use_regex": true,
"match_formulas": true,
},
},
{
name: "+cells-replace empty replace deletes match",
sc: CellsReplace,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--replacement", ""},
toolName: "replace_data",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"search_term": "foo",
"replace_term": "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, tt.sc, tt.args)
got := decodeToolInput(t, body, tt.toolName)
assertInputEquals(t, got, tt.wantInput)
if tt.wantOptions != nil {
opts, _ := got["options"].(map[string]interface{})
if opts == nil {
t.Fatalf("options missing: %#v", got)
}
for k, want := range tt.wantOptions {
if opts[k] != want {
t.Errorf("options[%q] = %v, want %v", k, opts[k], want)
}
}
}
})
}
}
func TestCellsReplace_RequireFlag(t *testing.T) {
t.Parallel()
// --replace not passed at all (vs empty string) should error.
stdout, stderr, err := runShortcutCapturingErr(t, CellsReplace, []string{
"--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--dry-run",
})
if err == nil {
t.Fatalf("expected error when --replace omitted; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), "replace") {
t.Errorf("expected message about --replace; got=%s|%s|%v", stdout, stderr, err)
}
}

View File

@@ -1,679 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_sheet_structure ───────────────────────────────────────
//
// Wraps get_sheet_structure (read) and modify_sheet_structure (write,
// operation-enum dispatch). All region/position arguments use A1-style
// strings (1-based row numbers like "3:7" / "5", or column letters like
// "C:F" / "C"); dim-* / resize never expose 0-based int indices on the CLI
// surface, so there is no inclusive/exclusive ambiguity across commands.
// parseA1Range / parseA1Position handle parsing into the 0-based ints that
// dim-move's native v3 endpoint expects.
//
// +rows-resize / +cols-resize live in lark_sheet_range_operations (different
// tool); they are only grouped under "工作表" for discoverability.
// SheetInfo wraps get_sheet_structure: row heights, column widths, hidden
// rows/cols, merged cells, row/column groups, and freeze counts for one
// sub-sheet (optionally limited to a range).
var SheetInfo = common.Shortcut{
Service: "sheets",
Command: "+sheet-info",
Description: "Get a sub-sheet's layout metadata: row heights, column widths, hidden rows/cols, merges, groups, freeze.",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+sheet-info"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, _, err := resolveSheetSelector(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
return invokeToolDryRun(token, ToolKindRead, "get_sheet_structure", sheetInfoInput(runtime, token, sheetID, sheetName))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_sheet_structure", sheetInfoInput(runtime, token, sheetID, sheetName))
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Frozen rows / columns are top-level fields and are returned regardless of --include.",
},
}
func sheetInfoInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
input := map[string]interface{}{"excel_id": token}
sheetSelectorForToolInput(input, sheetID, sheetName)
if r := strings.TrimSpace(runtime.Str("range")); r != "" {
input["range"] = r
}
if include := runtime.StrSlice("include"); len(include) > 0 {
if t := infoTypeFromInclude(include); t != "" {
input["info_type"] = t
}
}
return input
}
// infoTypeFromInclude maps the fine-grained --include vocabulary to the
// tool's coarse info_type enum. When --include spans multiple categories
// (or asks for "frozen", which is always returned), we fall back to "all".
func infoTypeFromInclude(include []string) string {
groups := map[string]string{
"row_heights": "row_heights_column_widths",
"col_widths": "row_heights_column_widths",
"hidden_rows": "hidden_infos",
"hidden_cols": "hidden_infos",
"groups": "group_infos",
"merges": "merged_cells_infos",
"frozen": "", // any info_type returns frozen; falling back to all is fine
}
seen := map[string]struct{}{}
for _, v := range include {
g, ok := groups[v]
if !ok || g == "" {
return "all"
}
seen[g] = struct{}{}
}
if len(seen) != 1 {
return "all"
}
for g := range seen {
return g
}
return "all"
}
// ─── +dim-* (modify_sheet_structure) ──────────────────────────────────
// DimInsert inserts blank rows / columns and optionally inherits style from
// the adjacent dimension.
var DimInsert = common.Shortcut{
Service: "sheets",
Command: "+dim-insert",
Description: "Insert blank rows or columns at a given position.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-insert"),
Validate: validateViaInput(dimInsertInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := dimInsertInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := dimInsertInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// dimInsertInput passes --position (1-based row number "3" or column letter
// "C") straight to the tool's `position` field; --count maps to `count`.
func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if !runtime.Changed("position") {
return nil, common.FlagErrorf("--position is required")
}
if !runtime.Changed("count") {
return nil, common.FlagErrorf("--count is required")
}
position := strings.TrimSpace(runtime.Str("position"))
if _, _, err := parseA1Position(position); err != nil {
return nil, common.FlagErrorf("invalid --position %q: %v", position, err)
}
count := runtime.Int("count")
if count <= 0 {
return nil, common.FlagErrorf("--count must be > 0 (got %d)", count)
}
input := map[string]interface{}{
"excel_id": token,
"operation": "insert",
"position": position,
"count": count,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
switch runtime.Str("inherit-style") {
case "before":
input["side"] = "before"
case "after":
input["side"] = "after"
}
return input, nil
}
// DimDelete deletes rows / columns — irreversible, high-risk-write.
var DimDelete = common.Shortcut{
Service: "sheets",
Command: "+dim-delete",
Description: "Delete rows or columns (irreversible).",
Risk: "high-risk-write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-delete"),
Validate: validateDimRangeOp("delete"),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Row/column deletion is irreversible. Always preview with --dry-run first.",
},
}
// validateDimRangeOp returns a Validate closure that delegates to
// dimRangeOpInput for shortcuts (delete/hide/unhide) whose builder takes an
// extra `op` argument. Token check happens here; the rest is the builder.
func validateDimRangeOp(op string) func(ctx context.Context, runtime *common.RuntimeContext) error {
return func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID := strings.TrimSpace(runtime.Str("sheet-id"))
sheetName := strings.TrimSpace(runtime.Str("sheet-name"))
_, err = dimRangeOpInput(runtime, token, sheetID, sheetName, op)
return err
}
}
// validateDimGroupOp is the group/ungroup counterpart of validateDimRangeOp.
func validateDimGroupOp(op string) func(ctx context.Context, runtime *common.RuntimeContext) error {
return func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID := strings.TrimSpace(runtime.Str("sheet-id"))
sheetName := strings.TrimSpace(runtime.Str("sheet-name"))
_, err = dimGroupInput(runtime, token, sheetID, sheetName, op)
return err
}
}
// DimHide / DimUnhide toggle visibility on a row/column range.
var DimHide = newDimRangeOpShortcut(
"+dim-hide", "Hide rows or columns within a range.", "hide", "write",
)
var DimUnhide = newDimRangeOpShortcut(
"+dim-unhide", "Unhide rows or columns within a range.", "unhide", "write",
)
// DimGroup / DimUngroup manage row/column outline groups.
var DimGroup = newDimGroupShortcut(
"+dim-group", "Group rows or columns into an outline (collapsible).", "group",
)
var DimUngroup = newDimGroupShortcut(
"+dim-ungroup", "Remove a row/column outline group.", "ungroup",
)
// DimFreeze freezes the first N rows or columns; --count 0 unfreezes that
// dimension.
var DimFreeze = common.Shortcut{
Service: "sheets",
Command: "+dim-freeze",
Description: "Freeze the first N rows or columns; --count 0 unfreezes the chosen dimension.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-freeze"),
Validate: validateViaInput(dimFreezeInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := dimFreezeInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := dimFreezeInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if !runtime.Changed("dimension") {
return nil, common.FlagErrorf("--dimension is required")
}
if !runtime.Changed("count") {
return nil, common.FlagErrorf("--count is required (0 unfreezes)")
}
if runtime.Int("count") < 0 {
return nil, common.FlagErrorf("--count must be >= 0")
}
dim := runtime.Str("dimension")
count := runtime.Int("count")
op := "freeze"
if count == 0 {
op = "unfreeze"
}
input := map[string]interface{}{"excel_id": token, "operation": op}
sheetSelectorForToolInput(input, sheetID, sheetName)
if op == "freeze" {
if dim == "row" {
input["freeze_rows"] = count
} else {
input["freeze_columns"] = count
}
}
return input, nil
}
// dimRangeOpInput builds the tool input for delete/hide/unhide/group/ungroup
// which all take a `range` string field. --range is a 1-based A1 closed range
// ("3:7" / "5" for rows, "C:F" / "C" for columns) and passes straight through
// after format validation.
func dimRangeOpInput(runtime flagView, token, sheetID, sheetName, op string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if !runtime.Changed("range") {
return nil, common.FlagErrorf("--range is required")
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
if _, _, _, err := parseA1Range(rangeStr); err != nil {
return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err)
}
input := map[string]interface{}{
"excel_id": token,
"operation": op,
"range": rangeStr,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
return input, nil
}
// newDimRangeOpShortcut builds the shared shape for hide / unhide.
func newDimRangeOpShortcut(command, desc, op, risk string) common.Shortcut {
return common.Shortcut{
Service: "sheets",
Command: command,
Description: desc,
Risk: risk,
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor(command),
Validate: validateDimRangeOp(op),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := dimRangeOpInput(runtime, token, sheetID, sheetName, op)
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, op)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
}
// newDimGroupShortcut builds the shared shape for group / ungroup. It adds
// --depth (currently unused server-side — accepted for forward-compat per
// the canonical spec) and --group-state (group only, defaults to expand).
func newDimGroupShortcut(command, desc, op string) common.Shortcut {
flags := flagsFor(command)
return common.Shortcut{
Service: "sheets",
Command: command,
Description: desc,
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flags,
Validate: validateDimGroupOp(op),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := dimGroupInput(runtime, token, sheetID, sheetName, op)
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := dimGroupInput(runtime, token, sheetID, sheetName, op)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
}
func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) (map[string]interface{}, error) {
input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, op)
if err != nil {
return nil, err
}
if op == "group" {
if gs := runtime.Str("group-state"); gs != "" {
input["group_state"] = gs
}
}
return input, nil
}
// ─── A1 parsing helpers ───────────────────────────────────────────────
// parseA1Range parses an A1 closed range ("3:7" / "5" / "C:F" / "C") into
// the inferred dimension ("row" or "column") and 0-based inclusive indices.
// Single-element form yields startIdx == endIdx. Mixing digits and letters
// across the two sides ("3:C") is rejected.
func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error) {
s = strings.TrimSpace(s)
if s == "" {
return "", 0, 0, fmt.Errorf("range is empty")
}
parts := strings.Split(s, ":")
if len(parts) > 2 {
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element")
}
dim1, idx1, err := parseA1Position(parts[0])
if err != nil {
return "", 0, 0, err
}
if len(parts) == 1 {
return dim1, idx1, idx1, nil
}
dim2, idx2, err := parseA1Position(parts[1])
if err != nil {
return "", 0, 0, err
}
if dim1 != dim2 {
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range")
}
if idx2 < idx1 {
return "", 0, 0, fmt.Errorf("end position is before start")
}
return dim1, idx1, idx2, nil
}
// parseA1Position parses a single A1 position element: pure digits → row
// (1-based number, returned as 0-based idx); pure letters → column (letters
// case-insensitive, "A" → 0, "AA" → 26).
func parseA1Position(s string) (dimension string, idx int, err error) {
s = strings.TrimSpace(s)
if s == "" {
return "", 0, fmt.Errorf("position is empty")
}
isDigits := true
isLetters := true
for _, r := range s {
if r < '0' || r > '9' {
isDigits = false
}
if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
isLetters = false
}
}
if isDigits {
n, _ := strconv.Atoi(s)
if n <= 0 {
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s)
}
return "row", n - 1, nil
}
if isLetters {
return "column", letterToColumnIndex(s), nil
}
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s)
}
// columnIndexToLetter converts a 0-based column index to the spreadsheet
// letter notation (0 → "A", 25 → "Z", 26 → "AA", 701 → "ZZ", 702 → "AAA").
// Used by +workbook helpers that need to format absolute column references.
func columnIndexToLetter(idx int) string {
if idx < 0 {
return ""
}
idx++
var out []byte
for idx > 0 {
idx--
out = append([]byte{byte('A' + idx%26)}, out...)
idx /= 26
}
return string(out)
}
// ─── +dim-move (native v3 move_dimension, cli_status: cli-only) ──────
//
// Moves a contiguous block of rows or columns to a new index in the same
// sheet via the native v3 move_dimension endpoint (not the One-OpenAPI
// dispatcher). CLI accepts --source-range (A1 closed range like "3:7" or
// "C:F") + --target (A1 single position like "12" or "H"); both are parsed
// into the 0-based int indices that v3 move_dimension expects.
var DimMove = common.Shortcut{
Service: "sheets",
Command: "+dim-move",
Description: "Move a contiguous block of rows or columns to a new position (re-numbers neighbors).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-move"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
if _, _, err := resolveSheetSelector(runtime); err != nil {
return err
}
_, err := buildDimMovePlan(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
return common.NewDryRunAPI().
POST(dimMovePath(token, sheetSelectorPlaceholder(sheetID, sheetName))).
Body(dimMoveBody(runtime)).
Set("spreadsheet_token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
// v3 move_dimension carries sheet_id in the path. Resolve
// sheet_name client-side when needed (reuses lookupSheetIndex
// which fetches workbook structure).
if sheetID == "" {
lookedID, _, err := lookupSheetIndex(ctx, runtime, token, "", sheetName)
if err != nil {
return err
}
sheetID = lookedID
}
data, err := runtime.CallAPI("POST", dimMovePath(token, sheetID), nil, dimMoveBody(runtime))
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// dimMovePlan is the parsed form of --source-range / --target.
type dimMovePlan struct {
dimension string // "row" / "column"
startIdx int // 0-based inclusive
endIdx int // 0-based inclusive
targetIdx int // 0-based; destination position (move inserts before this)
}
// buildDimMovePlan parses --source-range + --target and enforces that the
// target dimension matches the source. Used by both Validate and Execute.
func buildDimMovePlan(runtime flagView) (*dimMovePlan, error) {
if !runtime.Changed("source-range") || !runtime.Changed("target") {
return nil, common.FlagErrorf("--source-range and --target are required")
}
src := strings.TrimSpace(runtime.Str("source-range"))
dim, startIdx, endIdx, err := parseA1Range(src)
if err != nil {
return nil, common.FlagErrorf("invalid --source-range %q: %v", src, err)
}
tgt := strings.TrimSpace(runtime.Str("target"))
tgtDim, tgtIdx, err := parseA1Position(tgt)
if err != nil {
return nil, common.FlagErrorf("invalid --target %q: %v", tgt, err)
}
if tgtDim != dim {
return nil, common.FlagErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim)
}
return &dimMovePlan{dimension: dim, startIdx: startIdx, endIdx: endIdx, targetIdx: tgtIdx}, nil
}
// dimMovePath builds the native v3 move_dimension endpoint. sheet_id lives in
// the path (unlike the v2 dimension_range body that the earlier build used).
func dimMovePath(token, sheetID string) string {
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension",
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
}
func dimMoveBody(runtime *common.RuntimeContext) map[string]interface{} {
plan, err := buildDimMovePlan(runtime)
if err != nil {
// Validate has already rejected this case; emit an empty body
// rather than panic on the dry-run path.
return map[string]interface{}{}
}
dim := "ROWS"
if plan.dimension == "column" {
dim = "COLUMNS"
}
return map[string]interface{}{
"source": map[string]interface{}{
"major_dimension": dim,
"start_index": plan.startIdx,
"end_index": plan.endIdx,
},
"destination_index": plan.targetIdx,
}
}

View File

@@ -1,342 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
// TestSheetStructureShortcuts_DryRun covers all 8 shortcuts in
// lark_sheet_sheet_structure (sheet-info + 7 dim-*) and verifies that the
// CLI's A1-style --range / --position / --count flags map straight through
// to the tool's `range` / `position` / `count` fields (or are normalised
// per shortcut's wire shape).
func TestSheetStructureShortcuts_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sc common.Shortcut
args []string
toolName string
wantInput map[string]interface{}
}{
{
name: "+sheet-info with include single category → narrow info_type",
sc: SheetInfo,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--include", "row_heights,col_widths"},
toolName: "get_sheet_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"info_type": "row_heights_column_widths",
},
},
{
name: "+sheet-info with mixed include → all",
sc: SheetInfo,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--include", "row_heights,merges"},
toolName: "get_sheet_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"info_type": "all",
},
},
{
name: "+dim-insert row position=6 count=3 inherit-before",
sc: DimInsert,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "6", "--count", "3", "--inherit-style", "before"},
toolName: "modify_sheet_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "insert",
"sheet_id": testSheetID,
"position": "6",
"count": float64(3),
"side": "before",
},
},
{
name: "+dim-insert column position=C count=2",
sc: DimInsert,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "C", "--count", "2"},
toolName: "modify_sheet_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "insert",
"sheet_id": testSheetID,
"position": "C",
"count": float64(2),
},
},
{
name: "+dim-delete column B:D",
sc: DimDelete,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "B:D"},
toolName: "modify_sheet_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "delete",
"sheet_id": testSheetID,
"range": "B:D",
},
},
{
name: "+dim-hide row 3:5",
sc: DimHide,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "3:5"},
toolName: "modify_sheet_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "hide",
"sheet_id": testSheetID,
"range": "3:5",
},
},
{
name: "+dim-unhide column AA:AC",
sc: DimUnhide,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "AA:AC"},
toolName: "modify_sheet_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "unhide",
"sheet_id": testSheetID,
"range": "AA:AC",
},
},
{
name: "+dim-freeze row count=2 → freeze_rows",
sc: DimFreeze,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--count", "2"},
toolName: "modify_sheet_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "freeze",
"sheet_id": testSheetID,
"freeze_rows": float64(2),
},
},
{
name: "+dim-freeze count=0 → unfreeze",
sc: DimFreeze,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "column", "--count", "0"},
toolName: "modify_sheet_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "unfreeze",
"sheet_id": testSheetID,
},
},
{
name: "+dim-group row 1:5 fold",
sc: DimGroup,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--group-state", "fold"},
toolName: "modify_sheet_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "group",
"sheet_id": testSheetID,
"range": "1:5",
"group_state": "fold",
},
},
{
name: "+dim-ungroup row 1:5",
sc: DimUngroup,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5"},
toolName: "modify_sheet_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "ungroup",
"sheet_id": testSheetID,
"range": "1:5",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, tt.sc, tt.args)
got := decodeToolInput(t, body, tt.toolName)
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestDimRange_Validation covers the A1 range parser's edge cases routed
// through +dim-hide (any --range shortcut works; we just need to exercise
// the validator).
func TestDimRange_Validation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
want string
}{
{
name: "end before start",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "5:3", "--dry-run"},
want: "end position is before start",
},
{
name: "mix row+column",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "3:C", "--dry-run"},
want: "cannot mix row",
},
{
name: "invalid characters",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--dry-run"},
want: "expected pure digits",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, DimHide, tt.args)
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), tt.want) {
t.Errorf("expected %q substring; got=%s|%s|%v", tt.want, stdout, stderr, err)
}
})
}
}
// TestDimMove_DryRun verifies the native v3 move_dimension payload shape.
// CLI's --source-range "1:3" (1-based inclusive) is parsed into
// source.{start_index=0, end_index=2} (0-based inclusive), and sheet_id is
// carried in the path, not the body. --target "11" → destination_index=10.
func TestDimMove_DryRun(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, DimMove, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--source-range", "1:3", "--target", "11",
})
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1", len(calls))
}
c := calls[0].(map[string]interface{})
wantURL := "/sheets/v3/spreadsheets/" + testToken + "/sheets/" + testSheetID + "/move_dimension"
if !strings.Contains(c["url"].(string), wantURL) {
t.Errorf("url = %v, want suffix %v", c["url"], wantURL)
}
body, _ := c["body"].(map[string]interface{})
src, _ := body["source"].(map[string]interface{})
if src["major_dimension"] != "ROWS" {
t.Errorf("source.major_dimension = %v, want ROWS", src["major_dimension"])
}
if src["start_index"].(float64) != 0 {
t.Errorf("start_index = %v, want 0", src["start_index"])
}
if src["end_index"].(float64) != 2 {
t.Errorf("end_index = %v, want 2 (0-based inclusive)", src["end_index"])
}
if body["destination_index"].(float64) != 10 {
t.Errorf("destination_index = %v, want 10 (target \"11\" → 0-based 10)", body["destination_index"])
}
}
// TestDimMove_Column exercises the column path: --source-range "C:F" →
// COLUMNS / start=2 / end=5; --target "H" → destination_index=7.
func TestDimMove_Column(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, DimMove, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--source-range", "C:F", "--target", "H",
})
c := calls[0].(map[string]interface{})
body, _ := c["body"].(map[string]interface{})
src, _ := body["source"].(map[string]interface{})
if src["major_dimension"] != "COLUMNS" {
t.Errorf("major_dimension = %v, want COLUMNS", src["major_dimension"])
}
if src["start_index"].(float64) != 2 || src["end_index"].(float64) != 5 {
t.Errorf("source = %v, want start=2 end=5", src)
}
if body["destination_index"].(float64) != 7 {
t.Errorf("destination_index = %v, want 7", body["destination_index"])
}
}
// TestDimMove_MismatchedDimension verifies that mixing source row + target
// column (or vice versa) is rejected at Validate.
func TestDimMove_MismatchedDimension(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, DimMove, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--source-range", "1:3", "--target", "H", "--dry-run",
})
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), "must match --source-range") {
t.Errorf("expected dimension-mismatch guard; got=%s|%s|%v", stdout, stderr, err)
}
}
// TestParseA1Range covers parser edge cases directly.
func TestParseA1Range(t *testing.T) {
t.Parallel()
cases := []struct {
in string
dim string
start int
end int
wantErr bool
}{
{"3:7", "row", 2, 6, false},
{"5", "row", 4, 4, false},
{"C:F", "column", 2, 5, false},
{"C", "column", 2, 2, false},
{"aa:ac", "column", 26, 28, false}, // lower-case letters accepted
{"", "", 0, 0, true},
{"3:C", "", 0, 0, true},
{"7:3", "", 0, 0, true},
{"A1", "", 0, 0, true}, // cell ref, not a row/col range
{"3:5:7", "", 0, 0, true},
{"0", "", 0, 0, true}, // rows are 1-based
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
t.Parallel()
dim, start, end, err := parseA1Range(c.in)
if c.wantErr {
if err == nil {
t.Errorf("parseA1Range(%q) = (%q, %d, %d, nil), want error", c.in, dim, start, end)
}
return
}
if err != nil {
t.Fatalf("parseA1Range(%q) unexpected error: %v", c.in, err)
}
if dim != c.dim || start != c.start || end != c.end {
t.Errorf("parseA1Range(%q) = (%q, %d, %d), want (%q, %d, %d)", c.in, dim, start, end, c.dim, c.start, c.end)
}
})
}
}
// TestColumnIndexToLetter exercises the corner cases of the letter helper
// (still in use by lark_sheet_workbook.go for absolute column refs).
func TestColumnIndexToLetter(t *testing.T) {
t.Parallel()
cases := []struct {
idx int
want string
}{
{0, "A"}, {25, "Z"}, {26, "AA"}, {27, "AB"}, {51, "AZ"},
{52, "BA"}, {701, "ZZ"}, {702, "AAA"},
}
for _, c := range cases {
if got := columnIndexToLetter(c.idx); got != c.want {
t.Errorf("columnIndexToLetter(%d) = %q, want %q", c.idx, got, c.want)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,991 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
// ─── pure helpers: date serial, typed cell mapping ────────────────────
func TestTablePut_IsoDateToSerial(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want int
ok bool
}{
{"2024-01-15", 45306, true}, // the empirically verified anchor
{"2024-01-01", 45292, true},
{"2024-02-29", 45351, true}, // 2024 is a leap year
{"1899-12-31", 1, true}, // one day after the epoch
{"not-a-date", 0, false},
{"2024/01/15", 0, false}, // wrong separator
}
for _, tt := range cases {
got, err := isoDateToSerial(tt.in)
if tt.ok {
if err != nil {
t.Errorf("isoDateToSerial(%q) unexpected error: %v", tt.in, err)
continue
}
if got != tt.want {
t.Errorf("isoDateToSerial(%q) = %d, want %d", tt.in, got, tt.want)
}
} else if err == nil {
t.Errorf("isoDateToSerial(%q) = %d, want error", tt.in, got)
}
}
}
func TestTablePut_BuildTypedCell(t *testing.T) {
t.Parallel()
t.Run("string keeps literal + text format so digit-like ids survive read-back", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "id", Type: "string"}, "00123")
if err != nil {
t.Fatal(err)
}
if cell["value"] != "00123" {
t.Errorf("value = %#v, want \"00123\"", cell["value"])
}
if nf := numberFormatOf(cell); nf != "@" {
t.Errorf("number_format = %q, want @ (text format so +table-get infers string, not number)", nf)
}
})
t.Run("string stringifies a json.Number without scientific notation", func(t *testing.T) {
t.Parallel()
cell, _ := buildTypedCell(&tableColumnSpec{Name: "code", Type: "string"}, json.Number("123456789012345"))
if cell["value"] != "123456789012345" {
t.Errorf("value = %#v, want literal digits", cell["value"])
}
})
t.Run("number preserves json.Number", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "amt", Type: "number", Format: "#,##0"}, json.Number("259874"))
if err != nil {
t.Fatal(err)
}
if n, ok := cell["value"].(json.Number); !ok || n.String() != "259874" {
t.Errorf("value = %#v, want json.Number 259874", cell["value"])
}
if nf := numberFormatOf(cell); nf != "#,##0" {
t.Errorf("number_format = %q, want #,##0", nf)
}
})
t.Run("date converts to serial + default format", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date"}, "2024-01-15")
if err != nil {
t.Fatal(err)
}
if cell["value"] != 45306 {
t.Errorf("value = %#v, want serial 45306", cell["value"])
}
if nf := numberFormatOf(cell); nf != "yyyy-mm-dd" {
t.Errorf("number_format = %q, want default yyyy-mm-dd", nf)
}
})
t.Run("date honors explicit format", func(t *testing.T) {
t.Parallel()
cell, _ := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date", Format: "yyyy-mm"}, "2024-01-15")
if nf := numberFormatOf(cell); nf != "yyyy-mm" {
t.Errorf("number_format = %q, want yyyy-mm", nf)
}
})
t.Run("bool maps to boolean", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "b", Type: "bool"}, true)
if err != nil || cell["value"] != true {
t.Errorf("value = %#v (err=%v), want true", cell["value"], err)
}
})
t.Run("null is an empty cell that still carries format", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date"}, nil)
if err != nil {
t.Fatal(err)
}
if _, has := cell["value"]; has {
t.Errorf("null cell should have no value: %#v", cell)
}
if nf := numberFormatOf(cell); nf != "yyyy-mm-dd" {
t.Errorf("null date cell should still carry format, got %q", nf)
}
})
t.Run("type mismatches are rejected", func(t *testing.T) {
t.Parallel()
if _, err := buildTypedCell(&tableColumnSpec{Type: "number"}, "abc"); err == nil {
t.Error("number column accepting a string should error")
}
if _, err := buildTypedCell(&tableColumnSpec{Type: "date"}, json.Number("1")); err == nil {
t.Error("date column accepting a number should error")
}
if _, err := buildTypedCell(&tableColumnSpec{Type: "bool"}, "true"); err == nil {
t.Error("bool column accepting a string should error")
}
})
}
// numberFormatOf digs the number_format out of a built cell's cell_styles, or
// "" when absent.
func numberFormatOf(cell map[string]interface{}) string {
styles, ok := cell["cell_styles"].(map[string]interface{})
if !ok {
return ""
}
nf, _ := styles["number_format"].(string)
return nf
}
// ─── payload validation ───────────────────────────────────────────────
func TestTablePut_PayloadValidation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
json string
want string
}{
{"empty sheets", `{"sheets":[]}`, "at least one sheet"},
{"missing name", `{"sheets":[{"columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "name is required"},
{"duplicate name", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[]},{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "duplicate sheet name"},
{"no columns", `{"sheets":[{"name":"S","columns":[],"rows":[]}]}`, "columns must be non-empty"},
{"bad column type", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"timestamp"}],"rows":[]}]}`, "invalid type"},
{"column missing name", `{"sheets":[{"name":"S","columns":[{"type":"string"}],"rows":[]}]}`, "columns[0].name is required"},
{"row width mismatch", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"},{"name":"b","type":"string"}],"rows":[["x"]]}]}`, "column count"},
{"bad start_cell", `{"sheets":[{"name":"S","start_cell":"A","columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "start_cell"},
{"bad date value", `{"sheets":[{"name":"S","columns":[{"name":"d","type":"date"}],"rows":[["2025/03/31"]]}]}`, "must be ISO"},
{"number expects numeric", `{"sheets":[{"name":"S","columns":[{"name":"n","type":"number"}],"rows":[["abc"]]}]}`, "number expects"},
{"invalid json", `{not json`, "invalid JSON"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := parseTablePutPayload(stubFlagView{"sheets": tt.json})
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Errorf("want error containing %q, got %v", tt.want, err)
}
})
}
}
// stubFlagView is a minimal flagView backed by a map, for unit-testing the
// payload parser without a cobra command.
type stubFlagView map[string]string
func (s stubFlagView) Str(name string) string { return s[name] }
func (s stubFlagView) Bool(name string) bool { return s[name] == "true" }
func (s stubFlagView) Int(name string) int { return 0 }
func (s stubFlagView) Float64(name string) float64 { return 0 }
func (s stubFlagView) Changed(name string) bool { _, ok := s[name]; return ok }
func (s stubFlagView) StrArray(name string) []string { return nil }
func (s stubFlagView) StrSlice(name string) []string { return nil }
func (s stubFlagView) Command() string { return "+table-put" }
// ─── dry-run: create + write rendering ────────────────────────────────
const tablePutSheetsJSON = `{"sheets":[{"name":"月度","columns":[` +
`{"name":"门店","type":"string"},` +
`{"name":"月份","type":"date","format":"yyyy-mm"},` +
`{"name":"销售额","type":"number","format":"#,##0"}` +
`],"rows":[["北京","2024-01-15",259874]]}]}`
func TestTablePut_DryRunWrite(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, TablePut, []string{"--url", testURL, "--sheets", tablePutSheetsJSON})
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1 (set_cell_range only)", len(calls))
}
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
if input["excel_id"] != testToken {
t.Errorf("excel_id = %v, want %s", input["excel_id"], testToken)
}
if input["sheet_name"] != "月度" {
t.Errorf("sheet_name = %v, want 月度", input["sheet_name"])
}
if input["range"] != "A1:C2" {
t.Errorf("range = %v, want A1:C2 (1 header + 1 data row × 3 cols)", input["range"])
}
rows := input["cells"].([]interface{})
header := rows[0].([]interface{})
if hs := cellStyles(header[0]); hs["font_weight"] != "bold" {
t.Errorf("header cell should be bold, got %#v", header[0])
}
data := rows[1].([]interface{})
// 月份 (date) → serial 45306, number_format yyyy-mm
if v := cellValue(data[1]); v != float64(45306) {
t.Errorf("date cell value = %#v, want 45306 serial", v)
}
if nf := cellStyles(data[1])["number_format"]; nf != "yyyy-mm" {
t.Errorf("date number_format = %v, want yyyy-mm", nf)
}
// 销售额 (number) → 259874 preserved
if v := cellValue(data[2]); v != float64(259874) {
t.Errorf("number cell value = %#v, want 259874", v)
}
}
func cellValue(c interface{}) interface{} {
m, _ := c.(map[string]interface{})
return m["value"]
}
func cellStyles(c interface{}) map[string]interface{} {
m, _ := c.(map[string]interface{})
s, _ := m["cell_styles"].(map[string]interface{})
return s
}
// ─── validation through the cobra surface ─────────────────────────────
func TestTablePut_Validation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
want string
}{
{
name: "missing spreadsheet locator rejected",
args: []string{"--sheets", tablePutSheetsJSON},
want: "at least one",
},
{
name: "url and token are mutually exclusive",
args: []string{"--url", testURL, "--spreadsheet-token", testToken, "--sheets", tablePutSheetsJSON},
want: "mutually exclusive",
},
{
name: "bad column type rejected",
args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"foo"}],"rows":[]}]}`},
want: "invalid type",
},
{
name: "row width mismatch rejected",
args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"},{"name":"b","type":"string"}],"rows":[["only-one"]]}]}`},
want: "column count",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, TablePut, append(tt.args, "--dry-run"))
if err == nil {
t.Fatalf("expected validation error; got nil. stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), tt.want) {
t.Errorf("error missing %q; got=%s|%s|%v", tt.want, stdout, stderr, err)
}
})
}
}
// ─── execute paths with stubbed tools ─────────────────────────────────
// TestTablePut_ExecuteWrite drives the write path: a structure read maps the
// existing sheet by name, then a set_cell_range write fills it.
func TestTablePut_ExecuteWrite(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"数据","index":0}]}`)
write := toolOutputStub(testToken, "write", `{"updated_cells_count":2}`)
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"数据","columns":[{"name":"a","type":"string"},{"name":"b","type":"number"}],"rows":[["x",1]]}]}`},
structure, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 1 {
t.Fatalf("result sheets = %d, want 1: %#v", len(sheets), data)
}
s0, _ := sheets[0].(map[string]interface{})
if s0["name"] != "数据" || s0["sheet_id"] != testSheetID {
t.Errorf("sheet summary = %#v, want name=数据 sheet_id=%s", s0, testSheetID)
}
if s0["range"] != "A1:B2" {
t.Errorf("range = %v, want A1:B2", s0["range"])
}
}
// TestTablePut_ExecuteWriteCreatesMissingSheet covers the branch where the
// named sheet does not yet exist: a create precedes the write.
func TestTablePut_ExecuteWriteCreatesMissingSheet(t *testing.T) {
t.Parallel()
// First structure read sees only "Sheet1"; the payload targets "新表", so
// createSheet runs, and the follow-up read (FIFO: second stub) resolves the
// newly created sheet's id.
structBefore := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0}]}`)
structAfter := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"新表","index":1}]}`)
write := toolOutputStub(testToken, "write", `{"ok":true}`)
write.Reusable = true // modify_workbook_structure create + set_cell_range
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"新表","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`},
structBefore, structAfter, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 1 {
t.Fatalf("result sheets = %d, want 1", len(sheets))
}
if s0, _ := sheets[0].(map[string]interface{}); s0["sheet_id"] != testSheetID2 {
t.Errorf("created sheet id = %v, want %s", s0["sheet_id"], testSheetID2)
}
}
// TestTablePut_SheetCreateDims checks new-sheet sizing: small tables keep the
// 20×200 floor (unchanged behavior), wide/long tables grow past it (the fix for
// set_cell_range "exceeds sheet bounds"), and start_cell offset + header row are
// accounted for, with columns clamped to the backend's 200 ceiling.
func TestTablePut_SheetCreateDims(t *testing.T) {
t.Parallel()
bp := func(b bool) *bool { return &b }
cols := func(n int) []tableColumnSpec { return make([]tableColumnSpec, n) }
rows := func(n int) [][]interface{} { return make([][]interface{}, n) }
cases := []struct {
name string
spec tableSheetSpec
wantRows, wantCols int
}{
{"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, 200, 20},
{"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, 200, 37},
{"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, 501, 20},
{"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, 200, 42},
{"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, 500, 20},
{"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, 200, 200},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotRows, gotCols := sheetCreateDims(&tt.spec)
if gotRows != tt.wantRows || gotCols != tt.wantCols {
t.Errorf("sheetCreateDims = (%d rows, %d cols), want (%d, %d)", gotRows, gotCols, tt.wantRows, tt.wantCols)
}
})
}
}
// TestTablePut_ExecuteCreatesWideSheetWithDims is the regression test for the
// wide-table bug: a 25-column payload targeting a not-yet-existing sheet must
// create it with 25 columns (past the 20-column default) so the follow-up
// set_cell_range fits instead of failing with "exceeds sheet bounds".
func TestTablePut_ExecuteCreatesWideSheetWithDims(t *testing.T) {
t.Parallel()
structBefore := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0}]}`)
createStub := toolOutputStub(testToken, "write", `{"ok":true}`) // modify_workbook_structure create
structAfter := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"宽表","index":1}]}`)
writeStub := toolOutputStub(testToken, "write", `{"ok":true}`) // set_cell_range
const n = 25
cols := strings.TrimRight(strings.Repeat(`{"name":"c","type":"string"},`, n), ",")
vals := strings.TrimRight(strings.Repeat(`"x",`, n), ",")
payload := `{"sheets":[{"name":"宽表","columns":[` + cols + `],"rows":[[` + vals + `]]}]}`
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets", payload},
structBefore, createStub, structAfter, writeStub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
var wire map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &wire); err != nil {
t.Fatalf("decode create body: %v", err)
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
t.Fatalf("decode create tool input: %v", err)
}
if input["operation"] != "create" {
t.Fatalf("first write should be the create op, got %#v", input["operation"])
}
if input["columns"] != float64(n) {
t.Errorf("create columns = %#v, want %d (sized to the wide payload)", input["columns"], n)
}
if input["rows"] != float64(200) {
t.Errorf("create rows = %#v, want 200 (floor)", input["rows"])
}
}
// TestTablePut_ExecutePartialFailure covers the partial-success error path:
// a set_cell_range write fails mid-import and the structured error surfaces.
// TestTablePut_ExecuteTotalFailure: a single sheet whose write fails landed
// nothing — it must be a plain failure, NOT partial_success.
func TestTablePut_ExecuteTotalFailure(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"数据","index":0}]}`)
writeErr := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_write",
Body: map[string]interface{}{"code": 1254000, "msg": "boom"},
}
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"数据","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`},
structure, writeErr)
if err == nil {
t.Fatalf("expected failure; got nil. out=%s", out)
}
if strings.Contains(err.Error(), "partially applied") || strings.Contains(out, "partially applied") {
t.Errorf("single-sheet failure must NOT be partial_success; got err=%v out=%s", err, out)
}
if !strings.Contains(err.Error(), "failed") && !strings.Contains(out, "no sheets were written") {
t.Errorf("expected plain-failure message; got err=%v out=%s", err, out)
}
}
// TestTablePut_ExecutePartialFailure: first sheet's write lands, second fails →
// partial_success carrying the first sheet in written_sheets.
func TestTablePut_ExecutePartialFailure(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read",
`{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"汇总","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"明细","index":1}]}`)
writeOK := toolOutputStub(testToken, "write", `{"updated_cells_count":2}`)
writeErr := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_write",
Body: map[string]interface{}{"code": 1254000, "msg": "boom"},
}
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"汇总","columns":[{"name":"a","type":"string"}],"rows":[["x"]]},{"name":"明细","columns":[{"name":"a","type":"string"}],"rows":[["y"]]}]}`},
structure, writeOK, writeErr)
if err == nil {
t.Fatalf("expected partial-success error; got nil. out=%s", out)
}
if !strings.Contains(err.Error(), "partially applied") && !strings.Contains(out, "partially applied") {
t.Errorf("expected partial_success (not total failure); got err=%v out=%s", err, out)
}
// The failing sheet is named in the message; the written one lives in the
// structured written_sheets detail.
if !strings.Contains(err.Error(), "明细") {
t.Errorf("partial_success should name the failed sheet 明细; got err=%v", err)
}
}
// ─── +workbook-create typed --sheets path ─────────────────────────────
// TestWorkbookCreate_TypedMutualExclusion locks the Validate contract: the typed
// --sheets entry can't be combined with the untyped --headers/--values.
func TestWorkbookCreate_TypedMutualExclusion(t *testing.T) {
t.Parallel()
typed := `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`
for _, tc := range []struct {
name string
args []string
}{
{"sheets+headers", []string{"--title", "X", "--sheets", typed, "--headers", `["a"]`}},
{"sheets+values", []string{"--title", "X", "--sheets", typed, "--values", `[["x"]]`}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, tc.args)
if err == nil {
t.Fatalf("expected mutual-exclusion error; got nil (stderr=%s)", stderr)
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("want 'mutually exclusive' error; got %v", err)
}
})
}
}
// TestWorkbookCreate_EmptySheetsErrors locks the fix for an explicitly-given but
// empty --sheets (e.g. empty stdin / file): it must error, not silently fall
// through to creating an empty workbook.
func TestWorkbookCreate_EmptySheetsErrors(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, []string{"--title", "X", "--sheets", ""})
if err == nil {
t.Fatalf("expected error for empty --sheets; got nil (stderr=%s)", stderr)
}
if !strings.Contains(err.Error(), "empty") {
t.Errorf("want 'empty' error; got %v", err)
}
}
// TestWorkbookCreate_TypedAdoptsDefaultSheet covers the one-step typed create:
// the new workbook's default sheet is renamed to the first payload sheet's name
// and reused (no empty Sheet1 left behind), then written type-faithfully (the
// date lands as an Excel serial, not text).
func TestWorkbookCreate_TypedAdoptsDefaultSheet(t *testing.T) {
t.Parallel()
create := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{"spreadsheet_token": "shtTYPED", "title": "Demo"},
},
},
}
// lookupFirstSheetID and writeTypedSheets' listSheetIDsByName both read the
// structure; one reusable stub serves both, reporting only the default sheet.
structure := toolOutputStub("shtTYPED", "read", `{"sheets":[{"sheet_id":"shtDef","sheet_name":"Sheet1","index":0}]}`)
structure.Reusable = true
rename := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/shtTYPED/tools/invoke_write",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), "modify_workbook_structure") },
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"output": `{"ok":true}`}},
}
write := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/shtTYPED/tools/invoke_write",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), "set_cell_range") },
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"output": `{"updated_cells_count":4}`}},
}
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
"--title", "Demo",
"--sheets", `{"sheets":[{"name":"Sales","columns":[{"name":"d","type":"date"},{"name":"amt","type":"number"}],"rows":[["2024-01-15",1234.5]]}]}`,
}, create, structure, rename, write)
if err != nil {
t.Fatalf("typed create failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtTYPED" {
t.Errorf("spreadsheet_token = %v, want shtTYPED", data["spreadsheet"])
}
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
t.Fatalf("want 1 written sheet, got %#v", data["sheets"])
}
// Default sheet adopted: rename targets shtDef → "Sales" (no new sheet, no
// stray Sheet1).
renameInput := decodeToolInput(t, decodeRawEnvelopeBody(t, rename.CapturedBody), "modify_workbook_structure")
if renameInput["operation"] != "rename" || renameInput["sheet_id"] != "shtDef" || renameInput["new_name"] != "Sales" {
t.Errorf("rename should adopt default shtDef→Sales; got %#v", renameInput)
}
// The data write carries the date as serial 45306, proving the type-faithful path.
writeInput := decodeToolInput(t, decodeRawEnvelopeBody(t, write.CapturedBody), "set_cell_range")
cellsJSON, _ := json.Marshal(writeInput["cells"])
if !strings.Contains(string(cellsJSON), "45306") {
t.Errorf("date 2024-01-15 should be written as serial 45306; cells=%s", cellsJSON)
}
}
// TestWorkbookCreate_TypedDryRun verifies the dry-run previews create + a typed
// set_cell_range write with the date already converted to a serial.
func TestWorkbookCreate_TypedDryRun(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "Demo",
"--sheets", `{"sheets":[{"name":"S","columns":[{"name":"d","type":"date"}],"rows":[["2024-01-15"]]}]}`,
})
if len(calls) != 2 {
t.Fatalf("want 2 dry-run calls (create + typed write), got %d", len(calls))
}
raw, _ := json.Marshal(calls[1])
if !strings.Contains(string(raw), "45306") {
t.Errorf("typed dry-run write should contain serial 45306; got %s", raw)
}
}
func TestTablePut_StringifyCellValue(t *testing.T) {
t.Parallel()
cases := []struct {
in interface{}
want string
}{
{"plain", "plain"},
{json.Number("12345678901234"), "12345678901234"},
{true, "TRUE"},
{false, "FALSE"},
{3.5, "3.5"},
}
for _, tt := range cases {
if got := stringifyCellValue(tt.in); got != tt.want {
t.Errorf("stringifyCellValue(%#v) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestTablePut_DescribeJSONType(t *testing.T) {
t.Parallel()
cases := []struct {
in interface{}
want string
}{
{"x", "a string"},
{json.Number("1"), "a number"},
{true, "a boolean"},
{[]interface{}{}, "an array"},
{map[string]interface{}{}, "an object"},
{3.14, "float64"},
}
for _, tt := range cases {
if got := describeJSONType(tt.in); got != tt.want {
t.Errorf("describeJSONType(%#v) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestTablePut_HeaderAndMode(t *testing.T) {
t.Parallel()
bp := func(b bool) *bool { return &b }
// headerOn: overwrite writes header, append omits it by default, explicit wins
if !headerOn(&tableSheetSpec{}) {
t.Error("overwrite default should write header")
}
if headerOn(&tableSheetSpec{Mode: "append"}) {
t.Error("append should omit header by default")
}
if !headerOn(&tableSheetSpec{Mode: "append", Header: bp(true)}) {
t.Error("explicit header:true should override append default")
}
if headerOn(&tableSheetSpec{Header: bp(false)}) {
t.Error("explicit header:false should be honored")
}
// writeModeName
if writeModeName(&tableSheetSpec{}) != "overwrite" || writeModeName(&tableSheetSpec{Mode: "append"}) != "append" {
t.Error("writeModeName normalization wrong")
}
// buildSheetMatrix header toggle
s := &tableSheetSpec{Columns: []tableColumnSpec{{Name: "a", Type: "string"}}, Rows: [][]interface{}{{"x"}}}
if m, _ := buildSheetMatrix(s, true, false); len(m) != 1 {
t.Errorf("header off → 1 data row, got %d", len(m))
}
if m, _ := buildSheetMatrix(s, true, true); len(m) != 2 {
t.Errorf("header on → header + 1 data row, got %d", len(m))
}
}
func TestTablePut_BadModeRejected(t *testing.T) {
t.Parallel()
_, err := parseTablePutPayload(stubFlagView{"sheets": `{"sheets":[{"name":"S","mode":"upsert","columns":[{"name":"a","type":"string"}],"rows":[]}]}`})
if err == nil || !strings.Contains(err.Error(), "invalid") {
t.Errorf("mode \"upsert\" should be rejected, got %v", err)
}
}
// TestTablePut_AppendEmptySheetWritesHeader: appending to an EMPTY sheet still
// writes the header row, so column names aren't lost (and a later +table-get
// won't consume the first data row as the header).
func TestTablePut_AppendEmptySheetWritesHeader(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"新","index":0}]}`)
region := toolOutputStub(testToken, "read", `{}`) // empty sheet: no current_region → lastRow 0
write := toolOutputStub(testToken, "write", `{"ok":true}`)
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"新","mode":"append","columns":[{"name":"列A","type":"string"}],"rows":[["x"],["y"]]}]}`},
structure, region, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
var wire map[string]interface{}
if err := json.Unmarshal(write.CapturedBody, &wire); err != nil {
t.Fatalf("decode captured write body: %v", err)
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
t.Fatalf("decode tool input: %v", err)
}
cells, _ := input["cells"].([]interface{})
if len(cells) != 3 {
t.Fatalf("empty-sheet append should write header + 2 data rows = 3, got %d", len(cells))
}
if header, _ := cells[0].([]interface{}); len(header) > 0 {
if h0, _ := header[0].(map[string]interface{}); h0["value"] != "列A" {
t.Errorf("first row should be the header 列A; got %#v", h0)
}
}
if input["range"] != "A1:A3" {
t.Errorf("range = %v, want A1:A3 (header + 2 rows at top of empty sheet)", input["range"])
}
}
// TestTablePut_ExecuteAppend verifies append placement: data lands below the
// sheet's existing data (current_region A1:B5 → start at row 6) with no repeated
// header.
func TestTablePut_ExecuteAppend(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"日志","index":0}]}`)
region := toolOutputStub(testToken, "read", `{"current_region":"A1:B5","actual_range":"A1:B5"}`)
write := toolOutputStub(testToken, "write", `{"ok":true}`)
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"日志","mode":"append","columns":[{"name":"时间","type":"string"},{"name":"值","type":"number"}],"rows":[["t1",1],["t2",2]]}]}`},
structure, region, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
// inspect the set_cell_range request the append produced
var wire map[string]interface{}
if err := json.Unmarshal(write.CapturedBody, &wire); err != nil {
t.Fatalf("decode captured write body: %v", err)
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
t.Fatalf("decode tool input: %v", err)
}
if input["range"] != "A6:B7" {
t.Errorf("append range = %v, want A6:B7 (2 rows below last data row 5, no header)", input["range"])
}
if cells, _ := input["cells"].([]interface{}); len(cells) != 2 {
t.Errorf("append should write 2 data rows (no header), got %d", len(cells))
}
data := decodeEnvelopeData(t, out)
if s0, _ := data["sheets"].([]interface{})[0].(map[string]interface{}); s0["mode"] != "append" {
t.Errorf("summary mode = %v, want append", s0["mode"])
}
}
// TestTablePut_HeaderFalseAndAllowOverwrite checks header:false drops the
// header row and allow_overwrite:false reaches the tool input.
func TestTablePut_HeaderFalseAndAllowOverwrite(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, TablePut, []string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"S","header":false,"allow_overwrite":false,"columns":[{"name":"a","type":"string"}],"rows":[["x"],["y"]]}]}`})
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
if input["allow_overwrite"] != false {
t.Errorf("allow_overwrite = %v, want false", input["allow_overwrite"])
}
rows, _ := input["cells"].([]interface{})
if len(rows) != 2 {
t.Fatalf("header:false → 2 data rows only, got %d", len(rows))
}
first, _ := rows[0].([]interface{})[0].(map[string]interface{})
if first["value"] != "x" {
t.Errorf("header:false first cell = %v, want data 'x' (no header row)", first["value"])
}
}
// ─── +table-get ───────────────────────────────────────────────────────
func TestTableGet_SerialRoundTrip(t *testing.T) {
t.Parallel()
for _, iso := range []string{"2024-01-15", "2024-02-29", "2000-01-01", "1899-12-31"} {
s, err := isoDateToSerial(iso)
if err != nil {
t.Fatalf("isoDateToSerial(%s): %v", iso, err)
}
if back := serialToISO(float64(s)); back != iso {
t.Errorf("roundtrip %s → %d → %s", iso, s, back)
}
}
}
func TestTableGet_IsDateNumberFormat(t *testing.T) {
t.Parallel()
for _, nf := range []string{"yyyy-mm-dd", "yyyy-mm", "yyyy/m/d", "YYYY/MM/DD"} {
if !isDateNumberFormat(nf) {
t.Errorf("%q should be a date format", nf)
}
}
for _, nf := range []string{"#,##0", "0.00", "0.00%", "@", ""} {
if isDateNumberFormat(nf) {
t.Errorf("%q should not be a date format", nf)
}
}
}
func TestTableGet_InferColumnType(t *testing.T) {
t.Parallel()
mk := func(v interface{}, nf string) map[string]interface{} {
c := map[string]interface{}{"value": v}
if nf != "" {
c["cell_styles"] = map[string]interface{}{"number_format": nf}
}
return c
}
col := func(cells ...map[string]interface{}) [][]map[string]interface{} {
rows := make([][]map[string]interface{}, len(cells))
for i, c := range cells {
rows[i] = []map[string]interface{}{c}
}
return rows
}
if typ, f := inferColumnType(col(mk(45306.0, "yyyy-mm-dd")), 0); typ != "date" || f != "yyyy-mm-dd" {
t.Errorf("date col → %s/%s", typ, f)
}
if typ, f := inferColumnType(col(mk(100.0, "#,##0")), 0); typ != "number" || f != "#,##0" {
t.Errorf("number col → %s/%s", typ, f)
}
if typ, _ := inferColumnType(col(mk(true, "")), 0); typ != "bool" {
t.Errorf("bool col → %s", typ)
}
if typ, _ := inferColumnType(col(mk("x", "")), 0); typ != "string" {
t.Errorf("string col → %s", typ)
}
// digit-like value carrying text format (@) infers as string, not number —
// this is what makes +table-put's string columns (ids/postcodes) survive read-back.
if typ, _ := inferColumnType(col(mk(123.0, "@")), 0); typ != "string" {
t.Errorf("@-format numeric-looking col → %s, want string", typ)
}
if typ, _ := inferColumnType([][]map[string]interface{}{}, 0); typ != "string" {
t.Errorf("empty col → %s (want string)", typ)
}
// Mixed number+text degrades to string (self-consistent: every value is then
// a string), so the column round-trips and pandas doesn't choke. Numeric
// coercion of the dirty cells is left to the caller (pandas to_numeric).
if typ, _ := inferColumnType(col(mk(100.0, ""), mk("暂无", ""), mk(200.0, "")), 0); typ != "string" {
t.Errorf("mixed number+text col → %s, want string", typ)
}
// A bare number mixed into a date column must NOT stay date (would serial-
// convert the number into a bogus date) — degrades to string.
if typ, _ := inferColumnType(col(mk(45306.0, "yyyy-mm-dd"), mk(5.0, "")), 0); typ != "string" {
t.Errorf("date+bare-number col → %s, want string", typ)
}
}
func TestTableGet_CellToTyped(t *testing.T) {
t.Parallel()
mk := func(v interface{}) map[string]interface{} { return map[string]interface{}{"value": v} }
if v := cellToTyped(mk(45306.0), "date"); v != "2024-01-15" {
t.Errorf("date serial → %v, want 2024-01-15", v)
}
if v := cellToTyped(mk(100.0), "number"); v != 100.0 {
t.Errorf("number → %v", v)
}
if v := cellToTyped(mk(true), "bool"); v != true {
t.Errorf("bool → %v", v)
}
if v := cellToTyped(mk(""), "string"); v != nil {
t.Errorf("empty string → %v, want nil", v)
}
if v := cellToTyped(nil, "string"); v != nil {
t.Errorf("nil → %v, want nil", v)
}
if v := cellToTyped(mk("hi"), "string"); v != "hi" {
t.Errorf("string → %v", v)
}
}
// TestTableGet_DigitStringRoundTrip: a column +table-put wrote as string (text
// format @) reads back as string, not number — so leading-zero ids / postcodes
// survive instead of collapsing to a number.
func TestTableGet_DigitStringRoundTrip(t *testing.T) {
t.Parallel()
region := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+
`[{"value":"邮编"}],`+
`[{"value":"00123","cell_styles":{"number_format":"@"}}]`+
`]}]}`)
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--sheet-name", "S"}, region, cells)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
s0, _ := sheets[0].(map[string]interface{})
cols, _ := s0["columns"].([]interface{})
if c0, _ := cols[0].(map[string]interface{}); c0["type"] != "string" {
t.Errorf("@-format col 邮编 → type %v, want string", c0["type"])
}
rows, _ := s0["rows"].([]interface{})
if r0, _ := rows[0].([]interface{}); r0[0] != "00123" {
t.Errorf("value = %v, want \"00123\" (leading zero preserved)", r0[0])
}
}
// TestTableGet_ExecuteRoundTrip reads a sheet back and checks the output is the
// same typed protocol +table-put consumes: date serial → ISO, number preserved,
// types inferred from number_format.
func TestTableGet_ExecuteRoundTrip(t *testing.T) {
t.Parallel()
region := toolOutputStub(testToken, "read", `{"current_region":"A1:C2"}`)
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+
`[{"value":"门店"},{"value":"月份"},{"value":"销售额"}],`+
`[{"value":"北京"},{"value":45306,"cell_styles":{"number_format":"yyyy-mm"}},{"value":259874,"cell_styles":{"number_format":"#,##0"}}]`+
`]}]}`)
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--sheet-name", "销售"}, region, cells)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 1 {
t.Fatalf("want 1 sheet, got %d", len(sheets))
}
s0, _ := sheets[0].(map[string]interface{})
if s0["name"] != "销售" {
t.Errorf("name = %v, want 销售", s0["name"])
}
cols, _ := s0["columns"].([]interface{})
if len(cols) != 3 {
t.Fatalf("want 3 columns, got %d", len(cols))
}
c1, _ := cols[1].(map[string]interface{})
if c1["name"] != "月份" || c1["type"] != "date" || c1["format"] != "yyyy-mm" {
t.Errorf("col 月份 = %#v, want name=月份 date yyyy-mm", c1)
}
c2, _ := cols[2].(map[string]interface{})
if c2["type"] != "number" || c2["format"] != "#,##0" {
t.Errorf("col 销售额 = %#v, want number #,##0", c2)
}
rows, _ := s0["rows"].([]interface{})
r0, _ := rows[0].([]interface{})
if r0[1] != "2024-01-15" {
t.Errorf("date roundtrip = %v, want 2024-01-15 (serial 45306 → ISO)", r0[1])
}
if r0[2] != float64(259874) {
t.Errorf("number = %v, want 259874", r0[2])
}
}
func TestTableGet_DryRunIncludesCellRead(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, TableGet, []string{"--url", testURL, "--sheet-name", "S"})
found := false
for _, c := range calls {
body, _ := c.(map[string]interface{})["body"].(map[string]interface{})
if body == nil {
continue
}
if tn, _ := body["tool_name"].(string); tn == "get_cell_ranges" {
found = true
}
}
if !found {
t.Error("dry-run should include a get_cell_ranges read")
}
}
// TestTableGet_AllSheets covers the "read every sheet" path (no --sheet-name):
// get_workbook_structure lists sheets, then each is read in order.
func TestTableGet_AllSheets(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"s1","sheet_name":"A","index":0},{"sheet_id":"s2","sheet_name":"B","index":1}]}`)
regionA := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
cellsA := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"项"}],[{"value":"x"}]]}]}`)
regionB := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
cellsB := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"项"}],[{"value":"y"}]]}]}`)
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL}, structure, regionA, cellsA, regionB, cellsB)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 2 {
t.Fatalf("want 2 sheets (all), got %d", len(sheets))
}
got := []string{
sheets[0].(map[string]interface{})["name"].(string),
sheets[1].(map[string]interface{})["name"].(string),
}
if got[0] != "A" || got[1] != "B" {
t.Errorf("sheet names = %v, want [A B] in workbook order", got)
}
}

View File

@@ -1,108 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_undo ──────────────────────────────────────────────────
//
// Wraps:
// - undo_last (write) — powers +undo
//
// Reverses the most recent edits this CLI link made to a spreadsheet, addressed
// by document revision. Every write response carries `data.revision`; that
// number is the undo anchor. The backend records an inverse changeset for every
// write and indexes it by the revision it produced (see the undo design doc,
// "方案 A · rev 寻址"); +undo asks the backend executor to locate that inverse
// data through the revision pointer, verify nobody else changed the document
// since (tip / continuity / object-version / identity checks), re-apply it in
// reverse order on the node Workbook, and push the result upstream as a
// collaboration change. The CLI only triggers the tool — the read-back endpoint
// is space-internal and not reachable through the /open-apis gateway, so all
// the heavy lifting stays server-side.
//
// +undo carries no sheet selector: undo is scoped to the spreadsheet + this
// link's edit history, not a single sub-sheet. Selection:
// - (no flags) : undo the latest edit, if it was made by this caller
// - --rev N : undo anchored at revision N (from a prior write response);
// rejected when the document has moved past N
// - --steps N : undo the last N edits in one atomic call (default 1)
// Undo wraps undo_last: reverse the most recent edits made through this CLI
// link, anchored by the revision a prior write returned (--rev), defaulting
// to the latest edit.
var Undo = common.Shortcut{
Service: "sheets",
Command: "+undo",
Description: "Undo the most recent edits this CLI link made to a spreadsheet (anchored by a write's returned revision).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+undo"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
_, err = undoInput(runtime, token)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
input, _ := undoInput(runtime, token)
return invokeToolDryRun(token, ToolKindWrite, "undo_last", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
input, err := undoInput(runtime, token)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "undo_last", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Every write response carries data.revision — remember it; +undo --rev <that> undoes exactly that edit, and +recover --to-revision <that-1> is the full-rollback fallback.",
"Without --rev, +undo targets the document's latest edit — it succeeds only when that edit was made through this CLI link by you.",
"Repeated +undo steps back one edit at a time; --steps N undoes the last N edits in one atomic call. Already-undone edits are skipped automatically.",
"If anyone else edited the document after (or between) the edits you want to undo, +undo refuses entirely and suggests +recover — it never partially undoes or overwrites others' changes.",
"A success response with undone:0 plus warning_message means nothing was actually undone — the targeted revision wasn't produced by this caller, or was already undone.",
"Use --dry-run to preview the request before running it.",
},
}
// undoInput builds the undo_last tool body. --rev anchors the undo at the
// revision a prior write returned (omitted = latest); --steps selects how many
// edits to reverse in one atomic call. Network-free; shared by Validate,
// DryRun, and Execute.
func undoInput(runtime flagView, token string) (map[string]interface{}, error) {
input := map[string]interface{}{"excel_id": token}
if runtime.Changed("rev") {
rev := runtime.Int("rev")
if rev < 1 {
return nil, common.FlagErrorf("--rev must be a positive revision number (from a prior write's data.revision)")
}
input["rev"] = rev
}
steps := runtime.Int("steps")
if steps < 1 {
return nil, common.FlagErrorf("--steps must be >= 1")
}
input["steps"] = steps
return input, nil
}

View File

@@ -1,107 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// TestUndo_DryRun asserts the undo_last body for the three selection shapes:
// default (latest, steps=1), explicit --steps, and a --rev anchor. Numbers
// round-trip through the wire JSON as float64, matching the other dry-run
// body tests.
func TestUndo_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
wantInput map[string]interface{}
}{
{
name: "default undoes the latest edit",
args: []string{"--url", testURL},
wantInput: map[string]interface{}{
"excel_id": testToken,
"steps": float64(1),
},
},
{
name: "explicit --steps",
args: []string{"--url", testURL, "--steps", "3"},
wantInput: map[string]interface{}{
"excel_id": testToken,
"steps": float64(3),
},
},
{
name: "--rev anchors at a write's returned revision",
args: []string{"--spreadsheet-token", testToken, "--rev", "123"},
wantInput: map[string]interface{}{
"excel_id": testToken,
"rev": float64(123),
"steps": float64(1),
},
},
{
name: "--rev composes with --steps",
args: []string{"--url", testURL, "--rev", "123", "--steps", "2"},
wantInput: map[string]interface{}{
"excel_id": testToken,
"rev": float64(123),
"steps": float64(2),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, Undo, tt.args)
got := decodeToolInput(t, body, "undo_last")
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestUndo_Validation covers the XOR token check, the --rev lower bound, and
// the --steps lower bound.
func TestUndo_Validation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
wantMsg string
}{
{
name: "needs --url or --spreadsheet-token",
args: []string{},
wantMsg: "at least one of --url or --spreadsheet-token",
},
{
name: "--rev must be positive",
args: []string{"--url", testURL, "--rev", "0"},
wantMsg: "--rev must be a positive revision number",
},
{
name: "--steps must be >= 1",
args: []string{"--url", testURL, "--steps", "0"},
wantMsg: "--steps must be >= 1",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, Undo, append(tt.args, "--dry-run"))
if err == nil {
t.Fatalf("expected validation error; got nil. stdout=%s stderr=%s", stdout, stderr)
}
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, tt.wantMsg) {
t.Errorf("error message missing %q; got=%s", tt.wantMsg, combined)
}
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,72 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
// TestWorkbookExport_ExecuteExportOnly covers the no-download path: without
// --output-path, +workbook-export delegates to the shared drive export core
// with OutputDir="" so it creates + polls the export task and returns the ready
// file token without writing a local file (downloaded=false).
func TestWorkbookExport_ExecuteExportOnly(t *testing.T) {
stubs := []*httpmock.Stub{
{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"ticket": "tk_export"},
},
},
{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_export",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"result": map[string]interface{}{
"job_status": float64(0),
"file_token": "ftk_xlsx",
"file_name": "report.xlsx",
"file_size": float64(2048),
}},
},
},
}
out, err := runShortcutWithStubs(t, WorkbookExport, []string{
"--url", testURL, "--file-extension", "xlsx", "--as", "user",
}, stubs...)
if err != nil {
t.Fatalf("export-only execute failed: %v\n%s", err, out)
}
idx := strings.Index(out, "{")
if idx < 0 {
t.Fatalf("no JSON envelope:\n%s", out)
}
var env struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
}
if env.Data["ready"] != true {
t.Errorf("ready = %v, want true", env.Data["ready"])
}
if env.Data["downloaded"] != false {
t.Errorf("downloaded = %v, want false (no --output-path)", env.Data["downloaded"])
}
if env.Data["file_token"] != "ftk_xlsx" {
t.Errorf("file_token = %v, want ftk_xlsx", env.Data["file_token"])
}
if env.Data["doc_type"] != "sheet" {
t.Errorf("doc_type = %v, want sheet", env.Data["doc_type"])
}
}

View File

@@ -1,135 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
)
// chdirTemp switches into a fresh temp dir for the duration of the test and
// restores the original cwd afterwards. +workbook-import is the first sheets
// shortcut that stat()s a real local file, so these tests need a working dir.
func chdirTemp(t *testing.T) {
t.Helper()
orig, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(orig) })
}
// TestWorkbookImport_DryRunPinsSheetType verifies the shortcut delegates to the
// shared drive import core and hard-codes the import target type to "sheet".
func TestWorkbookImport_DryRunPinsSheetType(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
calls := parseDryRunAPI(t, WorkbookImport, []string{"--file", "./data.xlsx"})
var createBody map[string]interface{}
for _, c := range calls {
cm, _ := c.(map[string]interface{})
if u, _ := cm["url"].(string); u == "/open-apis/drive/v1/import_tasks" {
createBody, _ = cm["body"].(map[string]interface{})
}
}
if createBody == nil {
t.Fatalf("no import_tasks create call in dry-run: %#v", calls)
}
if createBody["type"] != "sheet" {
t.Errorf("import type = %v, want sheet (must be pinned regardless of file)", createBody["type"])
}
if createBody["file_extension"] != "xlsx" {
t.Errorf("file_extension = %v, want xlsx", createBody["file_extension"])
}
}
// TestWorkbookImport_RejectsNonSheetFile ensures a file that cannot become a
// spreadsheet (e.g. .docx) is rejected up front by the pinned-sheet validation.
func TestWorkbookImport_RejectsNonSheetFile(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("notes.docx", []byte("fake-docx"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
// Validate runs before DryRun, so the pinned-sheet check rejects .docx up
// front and the error surfaces through the normal envelope/err path.
stdout, stderr, err := runShortcutCapturingErr(t, WorkbookImport, []string{"--file", "./notes.docx", "--dry-run"})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "can only be imported") {
t.Errorf("expected .docx → sheet type-mismatch rejection; got stdout=%s stderr=%s err=%v", stdout, stderr, err)
}
}
// TestWorkbookImport_ExecuteCreatesSheet runs the full upload → create → poll
// flow against stubs and asserts the resulting URL is a /sheets/ link.
func TestWorkbookImport_ExecuteCreatesSheet(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
stubs := []*httpmock.Stub{
{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"file_token": "file_import_media"},
},
},
{
Method: "POST",
URL: "/open-apis/drive/v1/import_tasks",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"ticket": "tk_sheet"},
},
},
{
Method: "GET",
URL: "/open-apis/drive/v1/import_tasks/tk_sheet",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"result": map[string]interface{}{
"token": "shtcn_imported",
"type": "sheet",
"job_status": float64(0),
}},
},
},
}
out, err := runShortcutWithStubs(t, WorkbookImport, []string{"--file", "./data.csv", "--as", "user"}, stubs...)
if err != nil {
t.Fatalf("import execute failed: %v\n%s", err, out)
}
idx := strings.Index(out, "{")
if idx < 0 {
t.Fatalf("execute output has no JSON envelope:\n%s", out)
}
var env struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
}
if url, _ := env.Data["url"].(string); !strings.Contains(url, "/sheets/") {
t.Errorf("imported url = %q, want a /sheets/ link", url)
}
if tok, _ := env.Data["token"].(string); tok != "shtcn_imported" {
t.Errorf("token = %q, want shtcn_imported", tok)
}
}

View File

@@ -1,457 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
// TestWorkbookShortcuts_DryRun covers all 9 lark_sheet_workbook shortcuts
// (WorkbookInfo + 8 sheet-* variants) by asserting the One-OpenAPI body
// the dry-run renders. Together they exercise every dispatch arm of
// modify_workbook_structure plus the read tool.
func TestWorkbookShortcuts_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sc common.Shortcut
args []string
toolName string
wantInput map[string]interface{}
}{
{
name: "+workbook-info read",
sc: WorkbookInfo,
args: []string{"--url", testURL},
toolName: "get_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
},
},
{
name: "+sheet-create with all options",
sc: SheetCreate,
args: []string{"--url", testURL, "--title", "Q1", "--index", "1", "--row-count", "300", "--col-count", "10"},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "create",
"sheet_name": "Q1",
"target_index": float64(1),
"rows": float64(300),
"columns": float64(10),
},
},
{
name: "+sheet-delete by id",
sc: SheetDelete,
args: []string{"--url", testURL, "--sheet-id", testSheetID},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "delete",
"sheet_id": testSheetID,
},
},
{
name: "+sheet-rename by name",
sc: SheetRename,
args: []string{"--url", testURL, "--sheet-name", "汇总", "--title", "Q1 汇总"},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "rename",
"sheet_name": "汇总",
"new_name": "Q1 汇总",
},
},
{
name: "+sheet-copy without explicit title",
sc: SheetCopy,
args: []string{"--url", testURL, "--sheet-id", testSheetID},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "duplicate",
"sheet_id": testSheetID,
},
},
{
name: "+sheet-copy with new title and index",
sc: SheetCopy,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--title", "副本", "--index", "0"},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "duplicate",
"sheet_id": testSheetID,
"new_name": "副本",
"target_index": float64(0),
},
},
{
name: "+sheet-hide",
sc: SheetHide,
args: []string{"--url", testURL, "--sheet-id", testSheetID},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "hide",
"sheet_id": testSheetID,
},
},
{
name: "+sheet-unhide",
sc: SheetUnhide,
args: []string{"--url", testURL, "--sheet-id", testSheetID},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "unhide",
"sheet_id": testSheetID,
},
},
{
name: "+sheet-set-tab-color hex",
sc: SheetSetTabColor,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--color", "#FF0000"},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "set_tab_color",
"sheet_id": testSheetID,
"tab_color": "#FF0000",
},
},
{
name: "+sheet-set-tab-color empty clears",
sc: SheetSetTabColor,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--color", ""},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "set_tab_color",
"sheet_id": testSheetID,
"tab_color": "",
},
},
{
name: "+sheet-show-gridline",
sc: SheetShowGridline,
args: []string{"--url", testURL, "--sheet-id", testSheetID},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "show_gridline",
"sheet_id": testSheetID,
},
},
{
name: "+sheet-hide-gridline",
sc: SheetHideGridline,
args: []string{"--url", testURL, "--sheet-id", testSheetID},
toolName: "modify_workbook_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "hide_gridline",
"sheet_id": testSheetID,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, tt.sc, tt.args)
got := decodeToolInput(t, body, tt.toolName)
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestSheetMove_DryRunResolvePlaceholders verifies the move shortcut emits
// <resolve> placeholders for fields it would otherwise have to look up
// at execute time. DryRun must stay network-free.
func TestSheetMove_DryRunResolvePlaceholders(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
wantSheetID string
wantSourceIdx interface{}
}{
{
name: "id only, no source-index → both literal + placeholder",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--index", "0"},
wantSheetID: testSheetID,
wantSourceIdx: "<resolve>",
},
{
name: "name only → sheet_id placeholder + source_index placeholder",
args: []string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"},
wantSheetID: "<resolve:汇总>",
wantSourceIdx: "<resolve>",
},
{
name: "id + source-index → both literal",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--index", "0", "--source-index", "5"},
wantSheetID: testSheetID,
wantSourceIdx: float64(5),
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, SheetMove, tt.args)
input := decodeToolInput(t, body, "modify_workbook_structure")
if got := input["sheet_id"]; got != tt.wantSheetID {
t.Errorf("sheet_id = %#v, want %#v", got, tt.wantSheetID)
}
if got := input["source_index"]; got != tt.wantSourceIdx {
t.Errorf("source_index = %#v, want %#v", got, tt.wantSourceIdx)
}
if got := input["target_index"]; got != float64(0) {
t.Errorf("target_index = %#v, want 0", got)
}
})
}
}
// TestSheetDelete_HighRiskWriteRequiresYes verifies the framework gate on
// high-risk-write — exit code 10 (confirmation_required) without --yes.
func TestSheetDelete_HighRiskWriteRequiresYes(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, SheetDelete, []string{"--url", testURL, "--sheet-id", testSheetID})
if err == nil {
t.Fatalf("expected confirmation_required error; got nil. stdout=%s stderr=%s", stdout, stderr)
}
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, "confirmation_required") && !strings.Contains(combined, "requires confirmation") {
t.Errorf("expected confirmation envelope; got=%s|%s|%v", stdout, stderr, err)
}
}
// TestWorkbook_Validation covers a few critical validation paths shared
// across the package's helpers (XOR token, XOR sheet selector, required
// flags).
func TestWorkbook_Validation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
sc common.Shortcut
args []string
wantMsg string
}{
{
name: "+workbook-info needs --url or --spreadsheet-token",
sc: WorkbookInfo,
args: []string{},
wantMsg: "at least one of --url or --spreadsheet-token",
},
{
name: "+workbook-info rejects both url and token",
sc: WorkbookInfo,
args: []string{"--url", testURL, "--spreadsheet-token", testToken},
wantMsg: "mutually exclusive",
},
{
name: "+sheet-delete needs sheet selector",
sc: SheetDelete,
args: []string{"--url", testURL},
wantMsg: "at least one of --sheet-id or --sheet-name",
},
{
name: "+sheet-create requires --title",
sc: SheetCreate,
args: []string{"--url", testURL},
wantMsg: "required flag(s) \"title\" not set",
},
{
name: "+sheet-create row-count over cap",
sc: SheetCreate,
args: []string{"--url", testURL, "--title", "X", "--row-count", "999999"},
wantMsg: "--row-count must be between",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
if err == nil {
t.Fatalf("expected validation error; got nil. stdout=%s stderr=%s", stdout, stderr)
}
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, tt.wantMsg) {
t.Errorf("error message missing %q; got=%s", tt.wantMsg, combined)
}
})
}
}
// ─── +workbook-create / +workbook-export (legacy OAPI) ───────────────
// TestWorkbookCreate_DryRun verifies the two-step plan (create
// spreadsheet + optional set_cell_range follow-up) is rendered.
func TestWorkbookCreate_DryRun(t *testing.T) {
t.Parallel()
t.Run("minimal title only", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{"--title", "MySheet"})
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1 (no headers/data)", len(calls))
}
c := calls[0].(map[string]interface{})
if c["url"] != "/open-apis/sheets/v3/spreadsheets" {
t.Errorf("url = %v, want /open-apis/sheets/v3/spreadsheets", c["url"])
}
body, _ := c["body"].(map[string]interface{})
if body["title"] != "MySheet" {
t.Errorf("body.title = %v, want MySheet", body["title"])
}
})
t.Run("with headers and data → 2-step plan", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "Sales",
"--headers", `["Name","Score"]`,
"--values", `[["alice",95],["bob",88]]`,
})
if len(calls) != 2 {
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
}
fill := calls[1].(map[string]interface{})
if !strings.Contains(fill["url"].(string), "/sheet_ai/v2/spreadsheets/") {
t.Errorf("fill url = %v, want sheet_ai/v2 path", fill["url"])
}
body, _ := fill["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
if input["range"] != "A1:B3" {
t.Errorf("fill range = %v, want A1:B3 (1 header + 2 data rows × 2 cols)", input["range"])
}
})
}
// TestWorkbookCreate_DataValidation rejects bad JSON shape.
func TestWorkbookCreate_DataValidation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
want string
}{
{"headers not array", []string{"--title", "X", "--headers", `"abc"`}, "must be a JSON array"},
{"values not 2D", []string{"--title", "X", "--values", `["a","b"]`}, "must be an array"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, append(tt.args, "--dry-run"))
if err == nil || !strings.Contains(stdout+stderr+err.Error(), tt.want) {
t.Errorf("expected %q; got=%s|%s|%v", tt.want, stdout, stderr, err)
}
})
}
}
// TestWorkbookExport_DryRun verifies the export dry-run now delegates to the
// shared drive export core: a single create-task POST (poll + download are
// described inline rather than as separate api entries).
func TestWorkbookExport_DryRun(t *testing.T) {
t.Parallel()
t.Run("xlsx create-task body pins type=sheet", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"})
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1 (create export task)", len(calls))
}
create := calls[0].(map[string]interface{})
if create["url"] != "/open-apis/drive/v1/export_tasks" {
t.Errorf("url = %v", create["url"])
}
body, _ := create["body"].(map[string]interface{})
if body["type"] != "sheet" || body["file_extension"] != "xlsx" || body["token"] != testToken {
t.Errorf("create body = %#v", body)
}
})
t.Run("csv includes sub_id from --sheet-id", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookExport, []string{
"--url", testURL, "--file-extension", "csv", "--sheet-id", "sh1",
"--output-path", "/tmp/out.csv",
})
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1", len(calls))
}
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
if body["type"] != "sheet" || body["sub_id"] != "sh1" {
t.Errorf("csv export body = %#v (want type=sheet, sub_id=sh1)", body)
}
})
t.Run("csv requires --sheet-id", func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, WorkbookExport, []string{
"--url", testURL, "--file-extension", "csv", "--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "--sheet-id is required") {
t.Errorf("expected sheet-id guard; got=%s|%s|%v", stdout, stderr, err)
}
})
}
// assertInputEquals compares the decoded tool input map against the wanted
// fields. Extra fields in `got` are allowed (defaults, optional fields);
// every key in `want` must match exactly.
func assertInputEquals(t *testing.T, got, want map[string]interface{}) {
t.Helper()
for k, wv := range want {
gv, ok := got[k]
if !ok {
t.Errorf("missing input key %q (got=%#v)", k, got)
continue
}
if !deepEqualJSON(gv, wv) {
t.Errorf("input[%q] = %#v, want %#v", k, gv, wv)
}
}
}
// deepEqualJSON compares JSON-shaped values (post-Unmarshal) — handles
// the fact that numbers come back as float64 and maps as map[string]interface{}.
func deepEqualJSON(a, b interface{}) bool {
switch av := a.(type) {
case map[string]interface{}:
bv, ok := b.(map[string]interface{})
if !ok || len(av) != len(bv) {
return false
}
for k, v := range av {
if !deepEqualJSON(v, bv[k]) {
return false
}
}
return true
case []interface{}:
bv, ok := b.([]interface{})
if !ok || len(av) != len(bv) {
return false
}
for i := range av {
if !deepEqualJSON(av[i], bv[i]) {
return false
}
}
return true
}
return a == b
}

Some files were not shown because too many files have changed in this diff Show More