Files
larksuite__cli/shortcuts/sheets/lark_sheet_sheet_structure.go
xiongyuanwen-byted decc9549b5 refactor(sheets): keep the success path off stderr (#2533)
* feat(sheets): reject local-office tokens in +workbook-export

A locally opened Office file (a local_office_ / fake_office_ token, or an
interleaved OFL0X one) names a file the Lark client is showing, not a cloud
document, so the drive export task can only fail on the backend -- and it
fails late, after the create and poll round trips, with an opaque message.

Refuse it up front with a typed failed_precondition that says the workbook is
already a file on disk, and points at +workbook-import for callers who want a
cloud spreadsheet they can export later. The check runs in Validate (so
--dry-run is covered too) and again after the wiki hop in Execute, where the
real spreadsheet token is first known.

* refactor(sheets): report success-path advisories in the result, not on stderr

Every sheets shortcut that had something to say on a successful run said it on
stderr: ignored sub-op locators, emulated dimension semantics, the deprecated
--dimension/--count and +cells-batch-set-style spellings, the dropdown
option-error steer, and the upload/export stage lines in the compatibility
layer. PowerShell's native-command handling and most agent harnesses read
non-empty stderr as failure, so a working call reported itself as an error --
and the facts a caller actually needed sat outside the JSON they parse.

Pure stage text ("Writing image", "Waiting for export task") is deleted: it
duplicates what the result already proves. Everything decision-relevant moves
into the payload:

  - data.warnings           ignored locators, colliding freezes, the dropdown
                            option-error steer (also shown in --dry-run now)
  - data.effective_operation +dim-insert's anchor shift under --inherit-style
                            before, and the whole (rows, cols) state a freeze
                            leaves behind
  - data.deprecation        +cells-batch-set-style and +dim-freeze's legacy
                            flag pair, under a key of its own rather than
                            mixed into warnings
  - data.upload             how +media-upload sent the file

Clean calls keep their exact previous payload shape: every field above is
added only when it has something to report.

Scope is shortcuts/sheets/** on purpose. The remaining success-path stderr in
this domain comes from shared code (the drive export/import core behind
+workbook-export / +workbook-import, the multipart media helper, the auto-grant
helper), which other domains share; cleaning those up belongs to their own
change. The one sheets-owned exception is +workbook-import's extension
correction, which has no slot in the import core's output envelope -- it is
documented at the call site and allowlisted in the guard test.

Tests pin the contract (a successful run leaves stderr empty) and each new
field, plus a source scan that stops new direct ErrOut writes from appearing.

* docs(sheets): point the dropdown option-error warning at data.warnings

The --source-range flag help still told callers the option-error steer arrives
on stderr; it now rides in the result. Mirrors the same edit in the upstream
spec (canonical-spec/spec-tables/flags.json), so the next sync is a no-op.

* fix(sheets): keep export identifiers in +export output, tighten the stderr guard

Review follow-ups on the success-path stderr change:

- +export --output-path lost file_token: on the download branch the token
  reached the caller only through the deleted "Export complete: file_token=…"
  stderr line, and the payload carried just saved_path and size_bytes. Both
  file_token and ticket now ride in the download result, so a caller can
  re-download or resume without re-running the export.

- The stderr guard allowlisted a whole file, hiding any future write in it.
  It now matches one exact statement in one file and asserts that write still
  exists, so both a new write and a stale exception fail the test.

- The contract comments claimed more than the tests prove. They now state
  that only sheets-OWNED code is silent, name the three commands whose noise
  comes from shared implementations (+workbook-export, +workbook-import,
  +media-upload over 20MB), and a new test pins that the shared export core
  does still write -- failing, by design, once that core is cleaned up.

* fix(drive): keep the export and import cores off stderr on success

+workbook-export and +workbook-import delegate to drive.RunExport /
drive.RunImport, so the sheets success-path contract could not hold while
those cores narrated every step: task creation, each poll attempt, completion,
"still in progress", and the import's media upload. Callers that read
non-empty stderr as failure saw a finished export report itself as an error.

The stage text is deleted -- ticket, ready, status, file_token, token and
next_command are all already in the payload. What the narration alone carried
moves into the result:

  - poll               attempts / transient_failures / last_error, added only
                       when a poll actually had to be retried, so a caller can
                       tell a clean run from one that limped to the finish
  - warnings           markdown export falling back to the token as file name
                       after a failed title lookup
  - input_corrections  a caller-supplied record of inputs the CLI rewrote
                       before the request ran; sheets +workbook-import uses it
                       for a mislabeled .xls that is really an .xlsx, which was
                       its last stderr write

drive +export / +import get the same treatment, since they share these cores.
Clean runs keep their exact previous payload shape.

With this, the sheets stderr guard needs no allowlist, and the contract test
covers both workbook commands end to end. Two shared paths a sheets caller can
still reach stay noisy and are named in the contract comment: multipart media
upload over 20MB, and the bot-identity auto-grant warning.

* test(sheets): cover the annotation shapes and both guard call sites

Review follow-ups, all test-side except one comment:

- +dim-insert's effective_operation had no test: a regression could drop the
  emulated-anchor block and still keep stderr empty. Now asserted field by
  field, plus the negative case (--inherit-style after rewrites nothing, so it
  must not gain the block).

- The local-office guard's second call site had no test. A /wiki/ URL only
  reveals its backing token after get_node runs in Execute, so that branch is
  now covered, asserting both the typed rejection and that no export task was
  created.

- annotateSheetsResult's three payload shapes are pinned: object annotated in
  place, array/scalar preserved under `result`, and an empty tool result left
  without an invented `result: null`. The doc comment now spells out that last
  case instead of lumping it in with non-object output.

- The export poll summary test asserted transient_failures but not attempts,
  so a wrong or missing count would have passed.

* fix: preserve recovery state on failure paths and TTY liveness during polls

Review round 2. Removing the success-path narration also removed information
from paths that fail after remote work has started, and removed the only
liveness signal an interactive user had:

- drive +import / sheets +workbook-import: once the import task exists, the
  ticket is the only handle back to it. A poll failure returned bare, so the
  ticket -- previously visible through the polling line -- was lost. It now
  rides on the typed error together with the +task_result command.

- sheets +export --output-path: a download or save failure happens after the
  artifact is ready, so the error now carries ticket, file_token and the
  +export-download command; re-running the whole export is not the recovery.
  A poll timeout carries the ticket for the same reason.

- sheets +batch-update / +batch-chart-*: batch_update is fail-fast without
  rollback, so the ignored-locator and colliding-freeze advisories matter most
  exactly when the call fails part-way -- they decide the safe retry set. They
  are now attached to the typed error's hint as well as the success payload.

- Bounded polls and the import upload are wrapped in RuntimeContext.StartSpinner,
  which is gated on StderrIsTerminal and is a strict no-op for pipes, CI and
  captured output. A human terminal gets liveness back; a machine caller's
  stderr stays empty (the contract tests, which capture stderr, still pass).

+workbook-export's rejection of Office tokens also stopped assuming the caller
holds the file: a local_office_ / fake_office_ prefix means the workbook is
already on their disk, but an interleaved OFL0X token is a file stored in Lark
that may never have been downloaded, so that class is now pointed at
drive +download (then +workbook-import if they want a Lark spreadsheet).

Each behaviour above has a regression test; httpmock's CapturedBodies doc
comment is corrected, since it is appended on every match, not only for
Reusable stubs.
2026-08-28 12:48:19 +08:00

1029 lines
41 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"sort"
"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 := resolveSpreadsheetTokenExec(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"),
Tips: []string{
"Example: lark-cli sheets +dim-insert --url <URL> --sheet-name Sheet1 --position 3 --count 2 --inherit-style before",
"Rows vs columns comes from --position alone: a row number (3) inserts rows, a column letter (C) inserts columns — there is no --dimension flag.",
},
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)
dr := invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
switch {
case dimInsertNeedsBeforeStyleWarning(runtime):
dr.Set("warning_message", dimInsertBeforeStyleWarning)
case dimInsertAnchorShifted(runtime, input):
// --inherit-style before anchors one unit earlier (see
// dimInsertInput), so the previewed body carries a position the
// caller never typed. Unexplained, that reads as an off-by-one bug in
// exactly the artifact people dry-run to check for off-by-one bugs.
dr.Set("warning_message", fmt.Sprintf(
"note: the previewed position is %q, not the %q you passed — this is not an off-by-one. --inherit-style before is emulated by anchoring one row/column earlier and inserting after it, which lands in the same place while copying the PRECEDING style. The row/column still appears at %q.",
input["position"], strings.TrimSpace(runtime.Str("position")), strings.TrimSpace(runtime.Str("position"))))
}
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(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
}
// --inherit-style before is emulated by anchoring one row/column earlier
// and inserting after it (see dimInsertInput). The dry-run explains that
// shift; the executed call has to as well, or a caller diffing the
// request against what they typed reads it as an off-by-one.
if dimInsertAnchorShifted(runtime, input) {
out = annotateSheetsResult(out, "effective_operation", map[string]interface{}{
"requested_position": strings.TrimSpace(runtime.Str("position")),
"anchor_position": input["position"],
"side": input["side"],
"inherit_style": "before",
"note": "--inherit-style before is emulated: the request anchors one row/column earlier and inserts after it, which lands the new row/column at requested_position while copying the PRECEDING style.",
})
}
runtime.Out(out, nil)
return nil
},
}
// dimInsertBeforeStyleWarning fires only when the preceding-side style cannot
// be copied: --inherit-style before at the first row/column, where no
// preceding row/column exists. The row/column is still inserted before
// --position, just without style inheritance. (--inherit-style after has no
// such edge — a plain before-insert always has a following row/column.)
const dimInsertBeforeStyleWarning = "warning: --inherit-style before cannot copy the preceding row/column's style at the first row/column (no preceding row/column exists); inserting before --position without style inheritance. Copy styles separately if needed."
// dimInsertAnchorShifted reports whether the built body carries an anchor
// position different from the one the caller passed — true exactly when the
// --inherit-style before emulation moved it back one unit. Compared against the
// built input rather than recomputed, so the note can never claim a shift the
// request does not have.
func dimInsertAnchorShifted(runtime flagView, input map[string]interface{}) bool {
built, ok := input["position"].(string)
return ok && built != strings.TrimSpace(runtime.Str("position"))
}
func dimInsertNeedsBeforeStyleWarning(runtime flagView) bool {
return false
}
// dimInsertInput passes --position (1-based row number "3" or column letter
// "C") to the tool's `position` field; --count maps to `count`.
//
// +dim-insert's public contract is always "insert before --position";
// --inherit-style only selects which side's style the new row/column copies,
// never the insertion side. The sheet-ai tool always copies the *anchor*
// column's style (the target passed as position), regardless of side — so
// --inherit-style before is emulated by anchoring one unit earlier. See the
// switch below.
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, sheetsValidationForFlag("position", "--position is required")
}
if !runtime.Changed("count") {
return nil, sheetsValidationForFlag("count", "--count is required")
}
position := strings.TrimSpace(runtime.Str("position"))
if _, _, err := parseA1Position(position); err != nil {
return nil, sheetsValidationForFlag("position", "invalid --position %q: %v", position, err)
}
count := runtime.Int("count")
if count <= 0 {
return nil, sheetsValidationForFlag("count", "--count must be > 0 (got %d)", count)
}
input := map[string]interface{}{
"excel_id": token,
"operation": "insert",
"position": position,
"count": count,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
// --inherit-style selects which side's style the blank row/column copies;
// the insertion always lands *before* --position. Empirically the addCol
// backend copies the *anchor* column's style (the target passed as
// position), regardless of side — side only decides whether the blank lands
// before or after that anchor (verified live, see
// TestDimInsertInheritStyleSideMapping):
// after → side=before at P: the blank lands at P and anchor P becomes the
// *following* neighbour, so the blank copies it. Position unchanged.
// before → side=after at P-1: the blank still lands at P (insert-after-(P-1)
// == insert-before-P) and anchor P-1 becomes the *preceding*
// neighbour, so the blank copies it.
//
// The flag documents `after` as its default, and the omitted case takes that
// branch rather than leaving `side` off the request. This is belt-and-braces,
// not a fix: the backend's own default IS `before`, verified live 07-31 on a
// 4-way sheet (omitted / after / before / no-side-at-all all place the blank
// at --position, and omitted inherits the FOLLOWING row's style exactly as
// `after` does). Sending it explicitly just stops the documented default from
// depending on an undocumented server-side one.
// Pinned by TestDimInsertOmittedMatchesAfter.
switch runtime.Str("inherit-style") {
case "before":
if prev, ok := a1PositionBefore(position); ok {
input["side"] = "after"
input["position"] = prev
}
// First row/column: no preceding row/column exists, so fall back to a
// plain before-insert (dimInsertNeedsBeforeStyleWarning surfaces this).
default: // "after", and the omitted case it is the default for.
input["side"] = "before"
}
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: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Changed("ranges") {
if runtime.Changed("range") {
return sheetsValidationForFlag("ranges", "--range and --ranges are mutually exclusive; put every range into --ranges")
}
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
_, err = dimDeleteRangesOps(runtime, token, sheetID, sheetName)
return err
}
return validateDimRangeOp("delete")(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
if runtime.Changed("ranges") {
ops, _ := dimDeleteRangesOps(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
"excel_id": token,
"operations": ops,
})
}
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 := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
if runtime.Changed("ranges") {
ops, err := dimDeleteRangesOps(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
"excel_id": token,
"operations": ops,
})
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
}
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.",
`Scattered ranges: --ranges '["5:5","8:8","11:13"]' deletes them in one batch request (fail-fast, no rollback) — the CLI orders positions descending, so indexes never shift under you.`,
},
}
// dimDeleteRangesOps parses --ranges into one atomic batch of
// modify_sheet_structure delete ops, ordered DESCENDING by start position:
// deleting an earlier row shifts every later index up, so ascending
// execution deletes the wrong rows — the recurring failure of hand-built
// dim-delete batches in eval traces. Same-dimension and non-overlap are
// enforced up front.
func dimDeleteRangesOps(runtime flagView, token, sheetID, sheetName string) ([]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
raw, err := requireJSONArray(runtime, "ranges")
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, sheetsValidationForFlag("ranges", "--ranges must be a non-empty JSON array")
}
if len(raw) > maxBatchRanges {
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(raw))
}
type span struct {
raw string
start, end int
}
spans := make([]span, 0, len(raw))
dimension := ""
for i, v := range raw {
s, ok := v.(string)
if !ok {
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] must be a string", i)
}
dim, start, end, err := parseA1Range(s)
if err != nil {
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] %q: %v", i, s, err)
}
if dimension == "" {
dimension = dim
} else if dim != dimension {
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] %q is a %s range but earlier entries are %s ranges; one call deletes rows OR columns, not both", i, s, dim, dimension)
}
spans = append(spans, span{raw: strings.TrimSpace(s), start: start, end: end})
}
sort.Slice(spans, func(i, j int) bool { return spans[i].start > spans[j].start })
for i := 1; i < len(spans); i++ {
// Descending order: spans[i-1] starts at or after spans[i]. Overlap
// (or duplicate) makes the later delete hit already-shifted positions.
if spans[i].end >= spans[i-1].start {
return nil, sheetsValidationForFlag("ranges", "--ranges entries %q and %q overlap; merge them into one range", spans[i].raw, spans[i-1].raw)
}
}
ops := make([]interface{}, 0, len(spans))
for _, sp := range spans {
input := map[string]interface{}{
"excel_id": token,
"operation": "delete",
"range": sp.raw,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
ops = append(ops, map[string]interface{}{
"tool_name": "modify_sheet_structure",
"input": input,
})
}
return ops, nil
}
// 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 sets the sheet's freeze state. Freeze is full-state replacement
// server-side (verified 07-31 live), so every call states the WHOLE state:
// --rows/--cols name both axes at once, while the older --dimension/--count
// pair can only name one and therefore unfreezes the other.
var DimFreeze = common.Shortcut{
Service: "sheets",
Command: "+dim-freeze",
Description: "Freeze the first N rows and/or columns; this sets the whole freeze state, so an axis you do not name ends up unfrozen.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-freeze"),
Tips: []string{
"Example: lark-cli sheets +dim-freeze --url <URL> --sheet-name Sheet1 --rows 1 --cols 2 (holds the header row and the first 2 columns in one call)",
"Freezing is not additive: --dimension row --count 1 followed by --dimension column --count 2 leaves ONLY the columns frozen. Pass --rows/--cols together instead of calling twice",
"To unfreeze one axis but keep the other, state the survivor: --rows 0 --cols 2. Bare --count 0 clears both",
},
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)
dr := invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
// Surface the deprecation steer during the preview too: agents dry-run
// before executing, so a note only on the execute path arrives after the
// spelling is already committed to.
if note := dimFreezeLegacyNote(runtime); note != "" {
dr.Set("warning_message", note)
}
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(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
}
// Freezing replaces the whole state rather than adding to it, so the
// (rows, cols) this call actually leaves behind is the one fact a caller
// most often gets wrong — especially through the legacy
// --dimension/--count spelling, which can only name one axis.
rows, cols, _ := dimFreezeAxes(runtime)
out = annotateSheetsResult(out, "effective_operation", map[string]interface{}{
"frozen_rows": rows,
"frozen_cols": cols,
"spelling": dimFreezeSpelling(rows, cols),
})
runtime.Out(annotateSheetsDeprecation(out, dimFreezeLegacyNote(runtime)), nil)
return nil
},
}
// DEPRECATED(phase-2): +dim-freeze --dimension / --count — replaced by
// --rows / --cols. Phase 1: the flags keep working, are retired from the skill
// docs via bundle.json doc_hidden_flags in sheet-skill-spec and from --help via
// their hidden mark, and every use is steered by dimFreezeLegacyNote.
// Phase 2 removal: drop both rows from spec-tables/flags.json + their
// doc_hidden_flags entry, then dimFreezeLegacyNote, dimFreezeEquivalent, their
// call sites (this shortcut's DryRun/Execute and batchLegacyDimFreezeNotes) and
// the legacy branch in dimFreezeInput.
//
// The pair is a strict subset of --rows/--cols — every --dimension/--count call
// has a byte-identical --rows/--cols spelling (TestDimFreezeEquivalent pins
// this) — and it is the form that reads as if it scoped to one axis when the
// backend replaces the whole freeze state.
//
// dimFreezeLegacyNote returns "" for the modern form. It takes a flagView
// rather than a RuntimeContext so +batch-update can render the identical
// wording for a sub-op (see batchLegacyDimFreezeNotes).
func dimFreezeLegacyNote(runtime flagView) string {
if !runtime.Changed("dimension") && !runtime.Changed("count") {
return ""
}
return fmt.Sprintf(
"note: --dimension/--count is superseded by --rows/--cols, which state both axes at once; this call is equivalent to %s",
dimFreezeEquivalent(runtime))
}
// dimFreezeEquivalent renders the --rows/--cols spelling of a legacy
// --dimension/--count call, so the deprecation note carries the exact
// replacement instead of a generic pointer.
func dimFreezeEquivalent(runtime flagView) string {
rows, cols, _ := dimFreezeAxes(runtime)
return dimFreezeSpelling(rows, cols)
}
// dimFreezeAxes maps either request form onto the (rows, cols) freeze state it
// asks for. Pure mapping, no validation — dimFreezeInput validates first and
// then calls this, so the request body, the deprecation note and the batch
// collision note can never disagree about what a call means. ok is false when
// the flags name no state at all, or when the legacy pair is half-given
// (--count without --dimension); dimFreezeInput reports both.
func dimFreezeAxes(runtime flagView) (rows, cols int, ok bool) {
pairForm := runtime.Changed("dimension") || runtime.Changed("count")
axisForm := runtime.Changed("rows") || runtime.Changed("cols")
switch {
case axisForm && !pairForm:
return runtime.Int("rows"), runtime.Int("cols"), true
case pairForm && !axisForm:
if !runtime.Changed("dimension") || !runtime.Changed("count") {
return 0, 0, false
}
// A zero count clears BOTH axes — it is the bare unfreeze operation,
// which carries no dimension.
if count := runtime.Int("count"); count > 0 {
if runtime.Str("dimension") == "row" {
return count, 0, true
}
return 0, count, true
}
return 0, 0, true
}
return 0, 0, false
}
// dimFreezeSpelling renders a freeze state as the --rows/--cols flags that
// produce it. Single source of the replacement wording, shared by the
// deprecation note and the batch collision note.
func dimFreezeSpelling(rows, cols int) string {
switch {
case rows > 0 && cols > 0:
return fmt.Sprintf("--rows %d --cols %d", rows, cols)
case rows > 0:
return fmt.Sprintf("--rows %d", rows)
case cols > 0:
return fmt.Sprintf("--cols %d", cols)
}
return "--rows 0 --cols 0"
}
// dimFreezeInput builds the freeze body for both the standalone shortcut and
// the +batch-update sub-op, so the two stay byte-identical (see
// TestBatchOp_BodyMatchesStandalone).
//
// Two request forms, deliberately not mixable:
//
// - --rows / --cols state the complete target state in ONE operation. This
// is the only form that can hold both axes, because freeze is full-state
// replacement server-side (verified 07-31 live: freeze rows=1 then
// columns=2 in two calls ends at 0 rows / 2 columns — the second call
// drops the first axis). It is also the only form usable inside
// +batch-update, whose sub-ops are a static array that cannot read the
// current state to preserve an axis.
// - --dimension + --count is the original single-axis form, kept for
// compatibility. It necessarily unfreezes the axis it does not name.
func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
pairForm := runtime.Changed("dimension") || runtime.Changed("count")
axisForm := runtime.Changed("rows") || runtime.Changed("cols")
switch {
case pairForm && axisForm:
return nil, sheetsValidationForFlag("rows",
"give either --rows/--cols or --dimension/--count, not both — they are two ways to say the same thing; --rows/--cols is the one that can hold both axes at once")
case !pairForm && !axisForm:
// Prescribes only --rows/--cols: --dimension/--count is retired
// (DEPRECATED(phase-2)) and steering a caller into it here would earn
// them a deprecation note on the very next call.
return nil, sheetsValidationForFlag("rows",
"nothing to freeze: pass --rows N and/or --cols N — e.g. --rows 1 holds the header row, --rows 1 --cols 2 holds it plus the first 2 columns, --rows 0 --cols 0 unfreezes everything")
}
if axisForm {
for _, name := range []string{"rows", "cols"} {
if runtime.Changed(name) && runtime.Int(name) < 0 {
return nil, sheetsValidationForFlag(name, "--%s must be >= 0 (0 leaves that axis unfrozen)", name)
}
}
} else {
if !runtime.Changed("dimension") {
return nil, sheetsValidationForFlag("dimension", "--dimension is required alongside --count (or use --rows/--cols to set both axes at once)")
}
if !runtime.Changed("count") {
return nil, sheetsValidationForFlag("count", "--count is required alongside --dimension (0 unfreezes)")
}
if runtime.Int("count") < 0 {
return nil, sheetsValidationForFlag("count", "--count must be >= 0")
}
}
// Validation done; the flags-to-state mapping is dimFreezeAxes', shared with
// the deprecation and collision notes so the three cannot disagree.
rows, cols, _ := dimFreezeAxes(runtime)
// An all-zero target is the bare "unfreeze" operation, which carries no
// dimension and clears everything — the same request the old --count 0
// always sent.
input := map[string]interface{}{"excel_id": token, "operation": "unfreeze"}
if rows > 0 || cols > 0 {
input["operation"] = "freeze"
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if rows > 0 {
input["freeze_rows"] = rows
}
if cols > 0 {
input["freeze_columns"] = cols
}
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, sheetsValidationForFlag("range", "--range is required")
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
if _, _, _, err := parseA1Range(rangeStr); err != nil {
return nil, sheetsValidationForFlag("range", "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 := resolveSpreadsheetTokenExec(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 := resolveSpreadsheetTokenExec(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") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
parts := strings.Split(s, ":")
if len(parts) > 2 {
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
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") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
if idx2 < idx1 {
return "", 0, 0, fmt.Errorf("end position is before start") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
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") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
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) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
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) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
// 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)
}
// a1PositionBefore returns the A1 position one unit before s ("6" → "5",
// "C" → "B"), preserving row/column form. ok is false when s is the first
// row/column (row 1 / column A) — no earlier position — or is not a valid A1
// position. Callers validate via parseA1Position first, so in practice ok is
// false only at the first row/column.
func a1PositionBefore(s string) (pos string, ok bool) {
dimension, idx, err := parseA1Position(s)
if err != nil || idx == 0 {
return "", false
}
if dimension == "row" {
// idx is 0-based; the 1-based number one row earlier is idx itself.
return strconv.Itoa(idx), true
}
return columnIndexToLetter(idx - 1), true
}
// ─── +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 := resolveSpreadsheetTokenExec(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.CallAPITyped("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.ValidationErrorf("--source-range and --target are required").WithParams(sheetsInvalidParam("source-range", "required"), sheetsInvalidParam("target", "required"))
}
src := strings.TrimSpace(runtime.Str("source-range"))
dim, startIdx, endIdx, err := parseA1Range(src)
if err != nil {
return nil, sheetsValidationForFlag("source-range", "invalid --source-range %q: %v", src, err)
}
tgt := strings.TrimSpace(runtime.Str("target"))
tgtDim, tgtIdx, err := parseA1Position(tgt)
if err != nil {
return nil, sheetsValidationForFlag("target", "invalid --target %q: %v", tgt, err)
}
if tgtDim != dim {
return nil, common.ValidationErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim).WithParams(sheetsInvalidParam("target", "dimension mismatch"), sheetsInvalidParam("source-range", "dimension mismatch"))
}
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,
}
}