Compare commits

...

125 Commits

Author SHA1 Message Date
fangshuyu
0efde2f901 perf: lazy-load sheets shortcuts by invocation 2026-06-03 18:00:32 +08:00
zhengzhijie
5ac35fd9fd revert(cli): drop non-interactive proxy-warning silencing
WarnIfProxied's interactivity gate is a generic CLI/agent-UX change
unrelated to the sheets refactor / backward-compat scope of this branch.
Split out to a dedicated PR; restore WarnIfProxied to its single-arg form
here (warn.go, warn_test.go, factory_default.go callers).
2026-06-03 14:58:17 +08:00
xiongyuanwen-byted
1decb4399d test(sheets): drop redundant copyloopvar copy in required-flag parity test
Go 1.22+ scopes the loop var per iteration, so `cmd, business := cmd, business`
in TestBatchOp_RequiredFlagParity is a no-op that trips the repo's copyloopvar
linter (same cleanup as 2132472). Behavior unchanged; 45 sub-tests still pass.
2026-06-03 14:47:15 +08:00
zhengzhijie
d05fbcf041 refactor(sheets): drop unused int64 flag-type plumbing
No sheets flag-def declares an int64 type and RuntimeContext.Int64 had
zero callers, so remove the premature support: the RuntimeContext.Int64
helper, the registerShortcutFlagsWithContext int64 branch, the flagView
Int64 method + mapFlagView impl, and the typedDefault/validateRawTypes
int64 cases. float64 (consumed by --font-size) is kept.
2026-06-03 14:44:07 +08:00
xiongyuanwen-byted
cf47d9b5f9 test(sheets): data-driven required-flag parity contract for batch sub-ops
Adds TestBatchOp_RequiredFlagParity, the systematic standalone-vs-batch parity
check the branch review asked for. Data-driven over batchOpDispatch + flag-defs,
it asserts that for every batchable shortcut a +batch-update sub-op which
satisfies the sheet locator but omits the shortcut's business-required flags
fails in translateBatchOp, never silently defaulting.

This generalizes the hand-picked TestBatchOp_ErrorEquivalence / GuardsBeyondCobra
cases to the full 50-command surface and auto-covers shortcuts added later, so a
future refactor that moves a required check out of the shared *Input builder
(the failure mode behind the csv-put / sheet-move gaps) is caught here. 45
sub-tests run; locator-only commands (+sheet-delete / +sheet-hide / ...) have no
business-required flag to omit and are skipped. A missing-locator error is also
rejected so a bad fixture can't mask a real gap.
2026-06-03 14:40:19 +08:00
zhengzhijie
2132472b87 fix(sheets): silence nilerr/copyloopvar lint in batch type-check additions
- flag_view.go: annotate the fail-open return in validateRawTypes with
  //nolint:nilerr (matches the repo convention for intentional fail-open).
- execute_paths_test.go: drop the redundant tc := tc copy (Go 1.22+ scopes
  the loop var per iteration).
2026-06-03 14:33:19 +08:00
zhengzhijie
27c6333620 fix(sheets): resolve --sheet-name via title + keep bare sheet selectors verbatim
Two review findings on the backward-compat layer:

- lookupSheetIndex matched only sm["sheet_name"], but get_workbook_structure
  surfaces the sub-sheet display name as "title". Every --sheet-name path that
  relies on the lookup (e.g. +sheet-move) failed to resolve. Fall back to
  "title" when "sheet_name" is absent so either field resolves.

- +read / +write / +append fell back to --sheet-id when --range was omitted,
  then routed that bare sheet id through the range normalizer. A sheet id that
  looks A1-ish (letters+digits, e.g. "shtABC123") got mangled into
  "shtABC123!shtABC123:shtABC123". Split the sheet-only path from the
  range-normalization path: read/append pass the selector through verbatim,
  write builds the rect from the selector's A1.

Regression tests added for both paths; sheets suite green.
2026-06-03 14:25:43 +08:00
xiongyuanwen-byted
6bd21ac8c9 fix(cli): structured errors for unknown flags, print-schema, deprecated aliases
From the branch code-review doc (3 findings):

- pure-group UnknownFlags: installUnknownSubcommandGuard whitelists unknown
  flags so a mistyped subcommand still reaches the suggestion path, but a lone
  unknown flag before any subcommand (`sheets --badflag`) was swallowed and the
  group fell through to help + exit 0. unknownSubcommandRunE now recovers the
  swallowed tokens (from os.Args captured at Execute entry) and fails with a
  structured unknown_flag error; a misplaced but known flag (e.g. --format)
  still prints help.

- deprecated-alias notice: a backward-compat alias that fails a cobra-level
  required flag short-circuits before RunE, so the Validate/Execute-wrapped
  deprecation notice was dropped. Added Shortcut.OnInvoke, fired from PreRunE
  (ahead of ValidateRequiredFlags); and the root legacy error fallback now
  routes through the structured envelope when a deprecation is pending so the
  migration hint survives. Non-deprecated errors keep the plain output.

- --print-schema: runShortcut returned the bare error from PrintFlagSchema. It
  is now wrapped as a structured output.ExitError (type print_schema_error) so
  agent introspection can parse the failure.

Tests added for each path; cmd + sheets suites green.
2026-06-03 14:11:02 +08:00
xiongyuanwen-byted
e4309bb5b2 fix(sheets): harden batch type-checking and +workbook-create edge cases
From the branch code-review doc (3 findings):

- +batch-update sub-ops: `operations` is skipped by parse-time schema
  validation and mapFlagView coerces a type-mismatched scalar to its zero
  value, so "index":"abc" or "multiple":"true" silently became 0 / false and
  wrote to the wrong place. translateBatchOp now runs validateRawTypes, which
  checks each sub-op scalar against its flag-defs type and rejects mismatches.

- +workbook-create with empty arrays: buildInitialFillInput returned (nil,nil)
  for empty rows while the caller wrote fill["excel_id"] unconditionally, so
  --values '[]' panicked on a nil map and --headers '[]' produced an illegal
  "A1:1" range. It now also returns nil when no cells survive (maxCols==0
  guard) and Execute/DryRun skip the fill when fill==nil.

- +workbook-create partial failure: after the spreadsheet was created, a
  first-sheet lookup or fill failure returned a bare fmt.Errorf, losing the new
  token. It now returns a structured partial_success error carrying
  spreadsheet_token in the detail so callers can retry or clean up.

Tests added for each path; sheets suite green.
2026-06-03 14:10:59 +08:00
xiongyuanwen-byted
d6a0aadbe4 fix(cli): surface skill in deprecated_command notice
deprecation.Notice carries Skill, but the _notice.deprecated_command payload
dropped it, forcing callers to parse `message` to learn which skill to update.
Emit `skill` when set, alongside the existing `replacement`.
2026-06-03 12:37:25 +08:00
xiongyuanwen-byted
da198cf06a fix(sheets): enforce required-flag contract in batch sub-ops
Batch sub-ops reuse each shortcut's shared *Input builder through mapFlagView,
which seeds flag-defs defaults — so any required check that lives OUTSIDE the
builder (cobra MarkFlagsOneRequired, or a shortcut's own Validate) is silently
bypassed and the default value wins. Two gaps surfaced in PR review:

- +csv-put: with neither --start-cell nor --range set, start-cell's "A1"
  default won and the paste silently anchored at A1. Require an explicit anchor
  (guard on Changed, mirroring the standalone MarkFlagsOneRequired).
- +sheet-move: --index (plus >=0 bounds for index / source-index) was not
  enforced in the batch path; a missing --index silently moved the sheet to the
  front. Mirror SheetMove.Validate.

Also from the same review:
- +batch-update: an explicit --continue-on-error=false now wins over an
  --operations envelope's continue_on_error:true (guard on Changed, not value).
- validateDropdownRanges rejects malformed sheet!range ("!A1", "Sheet1!",
  "Sheet1!bad") at Validate instead of deferring to the server.

Tests added/updated for each path; full sheets suite green.
2026-06-03 12:37:25 +08:00
zhengzhijie
ad7b20935c fix(sheets): correct +dropdown-get sheet-locator doc, finalize skill to 2.0.0
+dropdown-get requires a mandatory sheet selector — its Validate calls
resolveSheetSelector — so drop it from the "no sheet locator" exception
list in SKILL.md. It was wrongly grouped with +dropdown-update/+dropdown-delete,
which take only --ranges. +dropdown-get's own per-shortcut badge (公共四件套)
was already correct. Also finalize the skill version 2.0.0-draft -> 2.0.0.
2026-06-03 12:33:58 +08:00
xiongyuanwen-byted
6e18185eaa chore(sheets): promote lark-sheets skill to 2.0.0
Drop the -draft suffix now that the refactored sheets skill is ready to
ship.
2026-06-03 10:34:49 +08:00
xiongyuanwen-byted
aec1bd4b0c chore: drop hardcoded ppe lane routing from base security headers
The x-tt-env/x-use-ppe headers forced every request onto the
ppe_moa_canvas pre-release lane; they were only meant for exercising the
sheets refactor against the staging backend. Remove them so the CLI
routes to production by default.
2026-06-02 21:25:57 +08:00
xiongyuanwen-byted
fe1b6b7bbb feat(sheets): flag pre-refactor backward aliases via _notice and --help grouping
Nudge users whose lark-sheets skill predates the refactor to migrate off
the pre-refactor aliases (+read, +write, ...), without requiring anyone
to read --help.

- internal/deprecation: process-level pending Notice slot (mirrors
  internal/skillscheck), surfaced in the JSON "_notice" envelope under a
  "deprecated_command" key.
- internal/cmdutil: shared DeprecatedGroupID cobra group + helper so both
  --help rendering and the unknown-subcommand path classify aliases the
  same way.
- shortcuts/register.go: applySheetsCompatGroups splits the aliases into a
  dedicated "update your skill" help group with "(-> +new)" pointers;
  wrapSheetsBackwardDeprecation records the notice from Validate/Execute so
  direct callers that never read --help still get flagged.
- cmd/root.go: extract composePendingNotice (now unit-testable) and split
  availableSubcommandNames into current vs deprecated buckets while still
  ranking unknown-subcommand suggestions across both.
2026-06-02 21:17:56 +08:00
zhengzhijie
4c24c6eb94 fix(sheets): resolve 30 golangci-lint v2.1.6 issues — copyloopvar, nilerr, unused
Removed 25 Go 1.22+ loop variable copies (copyloopvar) from test files where
tc := tc / tt := tt / c := c are no longer needed. Fixed 4 nilerr false
positives in flag_schema_validate.go by making intentional error discards
explicit (schema validation failures skip silently — best-effort guard).
Dropped unused batchOpDispatchKeys helper in batch_op_dispatch.go.
2026-06-02 20:23:05 +08:00
xiongyuanwen-byted
bc1cd72074 feat(sheets): restore pre-refactor shortcuts under backward/ for compatibility
The lark-sheets refactor renamed every shortcut (verb-noun → noun-verb,
e.g. +create-sheet → +sheet-create) and dropped the old commands. External
callers and the tests/cli_e2e/sheets suite still drive the legacy command
names (+create, +read, +write, +create-sheet, ...), which broke.

Re-add the pre-refactor implementations verbatim from main as an isolated
shortcuts/sheets/backward package (package rename only) and register
backward.Shortcuts() alongside sheets.Shortcuts(). Both sets mount under the
`sheets` service; their command names are fully disjoint (38 new vs 42 old,
zero overlap), so old and new commands coexist without collision.
2026-06-02 18:00:23 +08:00
xiongyuanwen-byted
d2aa27dac8 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-refactor
# Conflicts:
#	internal/cmdutil/factory_default.go
#	internal/transport/warn_test.go
#	internal/util/proxy.go
2026-06-02 17:53:17 +08:00
xiongyuanwen-byted
3bca545796 docs(sheets): sync skill description from spec (cloud-drive alias, lark-drive search, doubao routing) 2026-06-02 17:05:03 +08:00
xiongyuanwen-byted
a8b29a1cf1 chore: rename ppe x-tt-env lane to ppe_moa_canvas 2026-06-02 15:42:17 +08:00
xiongyuanwen-byted
9c7d5a4b96 Merge origin/main into feat/lark-sheets-refactor
Resolve conflicts:
- secheader.go: keep both HEAD ppe header and main AgentTrace header
- proxy_test.go: union of both test sets; align main's plugin tests to the
  2-arg WarnIfProxied(w, interactive) signature
- SKILL.md: take feat side (refactored 2.0.0 + others' value-render-option removal)
- drop 5 legacy sheets sources + cell-data.md (refactored away; main's
  #996/#1001/#984/#1073 covered by new code or no longer applicable)
2026-06-02 15:36:32 +08:00
zhengzhijiej-tech
ce9764ec2e Merge pull request #1215 from zhengzhijiej-tech/fix/delete-value-render-option
fix(sheets): drop dead --value-render-option flag from +csv-get
2026-06-02 15:00:26 +08:00
zhengzhijie
fc6e60ba5b fix(sheets): drop dead --value-render-option flag from +csv-get
+csv-get wraps get_range_as_csv, which has no value_render_option support
(absent from its input type, executor, and published tool schema — it always
returns formatted display text via getText()). The CLI passed the flag through
as a silent no-op: callers asking for raw_value/formula got formatted values.

Remove the flag from flag-defs, drop the value_render_option passthrough in
csvGetInput, and clean the stale SKILL references. The real value_render_option
capability is unchanged on +cells-get (get_cell_ranges) via --include formula.
2026-06-02 14:57:29 +08:00
xiongyuanwen-byted
5aba007f57 feat(sheets): strip UTF-8 BOM from stdin/@file flag input
resolveInputFlags now strips a leading UTF-8 BOM from content read via stdin
or @file, so it cannot corrupt the first CSV cell or break JSON parsing of
payloads like --operations / --cells downstream.

Also pulls the synced lark-sheets skill docs from sheet-skill-spec and drops
scheme-number tags from two test comments.
2026-06-02 14:26:01 +08:00
xiongyuanwen-byted
5c22f912aa feat(sheets): close P0-4 pivot gaps — enum case, clear→pivot-delete hint, placement warning
Last open P0 from the 50-trajectory analysis — the two pivot black holes:
upper-cased summarize_by, and pivots built over the source sheet that hit
#REF! and then couldn't be removed.

- enum case tolerance: validateAgainstSchema rewrites a case-only enum
  mismatch to the canonical (lower-case) spelling in place ("SUM" -> "sum")
  before the request is sent, killing the whole class instead of only
  hinting at it. Covers every nested enum (values[], calculated_fields[]);
  genuinely unknown values still fail with the existing did-you-mean message.
- +cells-clear / +cells-batch-clear: when the backend reports "can not find
  embedded block" (the range overlaps a pivot/chart), annotate the error
  with the real fix — clearing cells can't delete an embedded object; remove
  it with +pivot-delete / +chart-delete (id via +pivot-list / +chart-list).
  Applied to both shortcuts, a Tips line, and the cells-clear reference.
- +pivot-create: a --help Tips block making "omit --target-* -> backend
  auto-creates a sub-sheet, zero overwrite" the can't-miss default, plus a
  placement_warning (dry-run + execute output) when an explicit target sheet
  is set with no offset — definite when the target name matches the source
  sheet, conditional otherwise. Local-only, advisory, never blocks the call.

The placement_warning is structured output, not a stderr line, so it
survives non-interactive proxy-warning silencing and isn't swallowed by 2>&1.
2026-06-01 21:47:40 +08:00
xiongyuanwen-byted
7dd479df12 feat(cli): agent-friendly errors, proxy silencing, +csv-put --range
Agent-experience fixes distilled from analyzing 50 real sheets
trajectories, where the top failures were hallucinated command/flag
names, proxy warnings corrupting JSON on stdout, and --range carried
over from +csv-get to +csv-put.

- did-you-mean: unify the duplicated Levenshtein into a shared
  internal/suggest package and wire its prefix-weighted ranker into
  unknown-subcommand and unknown-flag errors; flag-parse errors now
  return a structured envelope with suggestions plus the full valid list,
  so agents recover from semantic typos (e.g. --query vs --find).
- proxy: suppress the one-time proxy warning in non-interactive
  (agent/CI/piped) runs so a 2>&1-merged stderr line cannot corrupt
  stdout JSON; interactive sessions still warn.
- sheets +csv-put: accept --range as an alias for --start-cell (parity
  with +csv-get / +cells-set) and echo the computed writes_range in
  dry-run and the success envelope, so agents see the paste footprint
  before it overwrites neighbours.
- docs(sheets): add an intent->command cheat-sheet to SKILL.md, a
  runtime-prerequisites section, and document the --range alias and
  writes_range behaviour.
2026-06-01 19:27:03 +08:00
zhengzhijiej-tech
46066de29e Merge pull request #1200 from zhengzhijiej-tech/feat/sheets-rows-get
feat(sheets): add --rows-json output flag to +csv-get
2026-06-01 18:35:55 +08:00
zhengzhijie
e58fa13716 feat(sheets): add --rows-json output flag to +csv-get
+csv-get --rows-json returns structured rows ({row_number, values:{col→cell}})
instead of the CSV string, so callers can address cells by row_number / column
letter without parsing [row=N] or RFC-4180 CSV. Same read, alternate output
shape — a flag on +csv-get (default stays CSV), not a separate shortcut, since
the two differ only in representation.

- CsvGet.Execute: --rows-json reshapes the response via assembleRowsJSON
  (parses annotated_csv into per-row records keyed by column letter; every
  logical row emitted; embedded newlines parsed into cell values)
- surfaces the under-read hint structurally as data_not_fully_read
- flag-defs.json + read-data reference synced from spec
2026-06-01 17:04:22 +08:00
zhengzhijiej-tech
f99b5bf32e Merge pull request #1160 from zhengzhijiej-tech/fix/eval-issues
docs(sheets): three SKILL-entry refinements derived from eval traces
2026-05-29 14:07:35 +08:00
xiongyuanwen-byted
74761a0e1c feat(sheets): add schema-driven JSON flag validation
Validate composite JSON flags (--properties, --cells, --options,
--border-styles, --sort-keys) against the embedded flag-schemas.json
on every standalone and +batch-update sub-op invocation, replacing
ad-hoc per-shortcut guards.

Supports the JSON Schema subset actually used upstream: type / enum
/ oneOf / required / properties / items / nullable / minimum /
maximum / minItems / maxItems / additionalProperties (true | false
| <schema>). Enum errors quote the failing value, truncate beyond 8
entries, and surface case-only "did you mean" hints (SUM -> sum).

Coverage: 18 / 19 (shortcut, flag) pairs. +batch-update --operations
stays validator-skipped; its translator already does richer per
sub-op checks. mapFlagView.Command() routes batch sub-ops through
the same (command, flag) -> schema pipeline as standalone.

loadFlagSchemas() is now sync.Once-guarded so parallel first access
from t.Parallel test sets and concurrent shortcut invocations is
race-free.

Removes superseded hand-written guards:
  - +pivot-create validateCreateInput / validatePivotCreateProps
  - +range-sort sort-keys per-item shape check
Test fixtures updated to be schema-conformant (chart position/size,
pivot summarize_by lowercase, cells 2D-array shape).
2026-05-29 12:46:23 +08:00
zhengzhijie
7d24e2b649 docs(sheets): tell agent to set +H for A1 references containing !
Synced from spec. The sheet-locator section now warns: when a flag value
contains `!` (--source / --range / --ranges with a cross-sheet prefix),
run `set +H` at the start of the bash session to disable history
expansion — otherwise interactive bash (e.g. inside an agent's shell
sandbox) lexes "Sheet1!A1" as a history reference and fails with
`event not found` before lark-cli ever sees the argument.

When the sheet name itself contains hyphens / spaces / non-ASCII
characters, the A1 reference also needs single quotes around the sheet
name per A1 notation, e.g. --source "'Sales-2025'!A1:D100".

Also flips the previous `--range` example to `--range 'Sheet1!A1:B2'`
(shell single-quote) for consistency.
2026-05-29 11:43:00 +08:00
zhengzhijie
e3eca666fb docs(sheets): require +workbook-info before guessing sheet name
Synced from spec. SKILL.md adds a new rule under the sheet-locator
section: unless the user has explicitly named a sheet, the agent must
call +workbook-info first to fetch sheets[].sheet_id / sheets[].title
rather than guessing the default `Sheet1`. The Chinese-language tables
this CLI is typically used against rarely use that literal name —
"数据" / "Sheet" (no digit) / "工作表 1" / business-named sheets are
far more common — so guessing wastes a round-trip before the agent
ends up calling +workbook-info anyway.

The 统一调用范式 example also switches its `--sheet-name "Sheet1"`
placeholder to `<真实表名>` to remove the inadvertent suggestion that
`Sheet1` is a sensible default.
2026-05-28 20:42:45 +08:00
zhengzhijiej-tech
da65e37647 Merge pull request #1152 from zhengzhijiej-tech/fix/eval-issues
docs(sheets): sync +pivot-create summarize_by lowercase enum values from spec
2026-05-28 20:36:42 +08:00
zhengzhijie
dce617eab2 docs(sheets): strip migration-history language from pivot reference / SKILL
Synced from spec. Removes "renamed from / no longer called / not
--sheet-id" style migration-history language that snuck into the
previous sync. Reference and SKILL now describe the current flag names
directly without referencing the old names.
2026-05-28 19:23:42 +08:00
zhengzhijie
9c3d30aa00 feat(sheets): rename +pivot-create sheet selector → --target-sheet-{id,name}
+pivot-create's placement selector (where the pivot table lands) is no
longer the generic --sheet-id / --sheet-name; it is now
--target-sheet-id / --target-sheet-name. The new names mark this as the
*output* sheet, distinct from the *data-source* sheet (which lives
inside --source as `'Sheet'!Range`). The other +pivot-{list,update,delete}
shortcuts keep --sheet-id / --sheet-name (their semantics are
"sheet that hosts the existing pivot", same as every other shortcut).

Motivation: an LLM agent reading the previous CLI surface saw +pivot-create
expose --sheet-id and assumed (as it had to) that it pointed at the data
source, like every other shortcut. The new flag name makes the intent
unambiguous at the call site, without relying on the agent having read
the narrative caveat in the reference doc.

Background: evaluation case U046 spent multiple rounds tripping on this
exact confusion before working around it with +sheet-rename.

Implementation:
- objectCRUDSpec gains createSheetIDFlag / createSheetNameFlag (with
  default-fallback accessors sheetIDFlagOnCreate / sheetNameFlagOnCreate);
  newObjectCreateShortcut + objectCreateInput consult the spec instead of
  hard-coded "sheet-id" / "sheet-name". pivotSpec sets target-sheet-*;
  every other create spec inherits the defaults.
- optionalSheetSelector (only used by pivot create) takes the two flag
  names as parameters so its mutex / control-char errors quote the names
  the user actually typed (--target-sheet-id, not --sheet-id).
- batch_op_dispatch: introduce sheetSelectorFlagsForSubOp(shortcut) →
  (idFlag, nameFlag) returning target-sheet-* for "+pivot-create" and
  the defaults otherwise; translateBatchOp uses it so +pivot-create
  sub-ops in +batch-update accept the same renamed input keys.
- Tests:
  - lark_sheet_object_crud_test.go: pivot-create cases switch args and
    expected error wording to target-sheet-*; extra assertion that the
    mutex error quotes the renamed flag (regression guard against
    flag-name drift between code and error message).
  - batch_op_contract_test.go: +pivot-create sub-op test uses
    target-sheet-id / target-sheet-name input keys; the body-vs-standalone
    contract loop reads the selector via sheetSelectorFlagsForSubOp so
    every other shortcut keeps using sheet-id / sheet-name.

Synced reference docs (skills/lark-sheets/{SKILL.md,
references/lark-sheets-pivot-table.md}) mirror the spec's new flag names,
narrative, 3-placement-strategy block, and SKILL.md exception bullet that
explains why +pivot-create's badge says 无 sheet 定位 yet still has
placement selectors (just under different names).

flag-defs.json synced from spec picks up the renamed flags + kind=own.

All sheets-package tests pass.
2026-05-28 19:15:38 +08:00
zhengzhijie
690e746896 docs(sheets): drop bash histexpand section, fix write-cells table escape
Sync from spec, refining the bash-quoting deep-dive added in 0f695b6:

- Drop the `## Shell 调用注意事项` section in SKILL.md and the inline
  `⚠️ bash 引号` callouts in lark-sheets-pivot-table.md and
  lark-sheets-write-cells.md. The 4-scenario quoting table + anti-pattern
  list turned out too verbose for the SKILL intro; single-quoted examples
  in the references are themselves enough nudge.
- lark-sheets-write-cells.md L146: fix the table cell escape from the
  malformed `'''Sheet1''!T1:T3'` (consecutive `''` are no-op empty
  strings) to `''\''Sheet1'\''!T1:T3'`, matching the bash example at
  L191 verbatim.

Net: 1 insertion, 40 deletions across 3 files.
2026-05-28 18:04:20 +08:00
zhengzhijie
0f695b60ec docs(sheets): wrap A1 sheet names in handwritten examples + bash histexpand guide
Synced from spec. Affects 4 reference md (chart / pivot-table / sparkline /
write-cells) and SKILL.md.

In addition to wrapping sheet names in single quotes in all remaining
handwritten examples (covers chart refs.value / nameRef, sparkline source,
write-cells --source-range, pivot-create narrative), SKILL.md gains a new
"Shell quoting for A1 references with !" section.

The new section addresses bash history expansion: in interactive bash
(e.g., ShellExec sandbox), unescaped `!Word` after `"..."` triggers
`bash: !A1: event not found`, dropping the command before lark-cli sees
it. The section gives 4 quoting strategies (shell single-quote outer,
`set +H` prefix, mixed quoting, sheet-rename fallback) and an anti-pattern
list.

Affected files:
- skills/lark-sheets/SKILL.md (new section)
- skills/lark-sheets/references/lark-sheets-chart.md
- skills/lark-sheets/references/lark-sheets-pivot-table.md
- skills/lark-sheets/references/lark-sheets-sparkline.md
- skills/lark-sheets/references/lark-sheets-write-cells.md
2026-05-28 17:51:00 +08:00
zhengzhijie
77f86ec2fd docs(sheets): wrap sheet names in single quotes in A1 examples
Synced from spec. Affects 3 reference md (pivot-table / batch-update /
write-cells) and 2 generated flag-data JSONs.

A1 examples like `Sheet1!A1:D100` now read `'Sheet1'!A1:D100` so models
default to single-quoted sheet names. Excel A1 notation requires single
quotes for sheet names containing hyphens / spaces / non-ASCII chars;
always-quoting is also valid for plain names, so this is the safer default
to teach.

Affected flags:
- +pivot-create --source
- +dropdown-update --ranges / --source-range
- +dropdown-delete --ranges
- +dropdown-set --source-range
- +cells-batch-set-style --ranges
- +cells-batch-clear --ranges
2026-05-28 17:38:15 +08:00
zhengzhijie
8e84f47d3e docs(sheets): sync +pivot-create summarize_by lowercase enum values from spec 2026-05-28 16:34:34 +08:00
zhengzhijiej-tech
5ebb1398e7 Merge pull request #1138 from zhengzhijiej-tech/feat/dim-resize-a1-range-schema
refactor(sheets): switch dim-* / rows-cols-resize to A1-string range schema
2026-05-27 19:59:24 +08:00
zhengzhijie
0476dec83c docs(sheets): sync +pivot-create placement reference from spec
Companion sync from sheet-skill-spec — the canonical reference rewrites
+pivot-create's "5 placement-related flags" rundown into a clearer
"4 placement-related flags" form (--target-sheet-id was already removed
in #1130, this updates the prose accordingly), and clarifies that
--sheet-id / --sheet-name on +pivot-create are the *placement* sheet
(not the source-data sheet), with omit-both as the strongly-recommended
default.

Also picks up a base-side --target-position description tweak that
dropped the now-stale "与 --target-sheet-id 配套" reference.

No CLI surface change.
2026-05-27 19:46:36 +08:00
zhengzhijie
69d2851163 refactor(sheets): switch dim-* / rows-cols-resize to A1-string range schema
The 9 row/column-region shortcuts used to share two int flags --start /
--end with inconsistent end semantics across commands — +dim-insert /
-delete / -hide / -unhide / -group / -ungroup treated --end as exclusive,
while +dim-move / +rows-resize / +cols-resize treated it as inclusive.
The skill reference even called this out as "the highest-frequency
off-by-one source", patched in docs rather than at the surface. Three
underlying tool schemas (position+count, A1 range string, 0-based int
pair) were all flattened onto the same --start/--end pair, which forced
a different normaliser per command and pushed mental math (count =
end - start) onto every caller.

Schema (sourced from base, regenerated via sheet-skill-spec, mirrored
into shortcuts/sheets/data/ and skills/lark-sheets/):

  +dim-insert                                    --position + --count
    rows: "3"; columns: "C". --count rows/columns
    inserted *before* --position.
  +dim-delete / -hide / -unhide / -group / -ungroup
                                                 --range
  +rows-resize / +cols-resize                    --range
    A1 closed range. Rows: "3:7" or "5". Columns: "C:F" or "C".
    Mixing letters and digits in one range is rejected.
  +dim-move                                      --source-range + --target
    --target must match --source-range's dimension (both row or both
    column). The move places the source block *before* --target.

Wire-shape preserved: modify_sheet_structure still receives `position`
+ `count` (insert) or a `range` A1 string (other dim-* ops); v3
move_dimension still receives 0-based inclusive ints (CLI parses the
A1 strings into them); resize_range still receives a two-sided A1
range (single-element form is expanded to "N:N" before send).

This is a flag-surface break (--start / --end / --dimension flags
removed from these 9 shortcuts); --dimension stays only on +dim-freeze
since it has no range to derive from.

Code: A1 parser added (parseA1Range / parseA1Position /
letterToColumnIndex reused from write_cells); dimRange / dimRangeFull /
dimPosition deleted; dim-move switches to source-range + target parsing;
resize gains a same-dimension guard so +rows-resize rejects "A:C" with
a clear "+rows-resize expects row numbers" message.

Tests: TestSheetStructureShortcuts_DryRun / TestDimMove_DryRun /
TestDimMove_Column / TestDimMove_MismatchedDimension /
TestDimRange_Validation / TestParseA1Range / TestResize_TypeAndSizeGuards
/ TestRangeOperationsShortcuts_DryRun all rewritten against the new
schema. Batch contract trio (BodyMatchesStandalone /
ErrorEquivalence / RejectsBadSubOpInput) and
TestBatchOp_DispatchCoversReportedBugs likewise. Full
`go test ./shortcuts/sheets/` passes.
2026-05-27 19:36:07 +08:00
xiongyuanwen-byted
ce5878e3c4 docs(sheets): clarify filter/filter-view rules update is whole-set PUT
Synced from upstream tools-schema. The rules field on manage_filter_object and manage_filter_view_object now documents update as whole-set PUT semantics: submitted rules become the complete rule set, all existing columns' rules are cleared first, columns not listed lose their old rules (no merge), and [] clears everything. Description-only change, no structural/field change.
2026-05-27 19:16:04 +08:00
zhengzhijiej-tech
e85afd68d2 Merge pull request #1130 from zhengzhijiej-tech/fix/pivot-create-schema-alignment
fix(sheets): allow +pivot-create to omit both sheet selectors
2026-05-27 17:01:46 +08:00
zhengzhijie
a09593a0fe fix(sheets): allow +pivot-create to omit both sheet selectors
manage_pivot_table_object treats sheet_id / sheet_name as the placement
target — when both are absent, handleCreate() auto-creates a new sub-sheet
to host the pivot table. The CLI's flag schema didn't reflect this:

- Exposed a third flag --target-sheet-id that mapped to the same wire
  field as --sheet-id, leaving the caller unsure which one to use
- --sheet-id / --sheet-name had "XOR with the other" descriptions that
  read like "operation context", so callers (especially LLM tool callers)
  felt obligated to set one — frequently the source sheet — which
  silently disabled the backend's auto-create guardrail and dropped the
  pivot at A1, overlapping the source data

Wire change (synced from sheet-skill-spec): drop the duplicate
--target-sheet-id flag; rewrite --sheet-id / --sheet-name descriptions
to make the placement-target semantics explicit and call out that
omitting both is the recommended path.

Implementation change (this PR): add an at-most-one sheet-selector
helper and let object create-shortcuts opt into it.

- helpers.go: new optionalSheetSelector (both empty allowed; both set
  still rejected; control-char validation unchanged). requireSheetSelector
  is untouched — every existing caller keeps the exactly-one contract.
- lark_sheet_object_crud.go: objectCRUDSpec gains
  allowEmptySheetSelectorOnCreate; objectCreateInput dispatches to
  optionalSheetSelector when it's set. Only pivotSpec opts in;
  chart / cond-format / sparkline / filter-view / float-image keep
  the existing require semantics. DryRun and Execute switch to direct
  flag extraction (same pattern Validate already used) so the XOR
  check happens in exactly one place (the builder).
- pivotSpec: drop the enhanceCreateInput branch that read the now-removed
  --target-sheet-id flag.
- Tests: TestPivotCreate_SheetSelectorSemantics covers both-empty /
  both-set / single-set; TestObjectCreate_RequiresSheetSelector
  regresses chart / cond-format / sparkline / filter-view to lock the
  scope of the relaxation.
2026-05-27 16:11:07 +08:00
xiongyuanwen-byted
60c61d8157 docs(sheets): sync lark-sheets skill from spec
Mirror the canonical-spec reference fixes into the consumer skill:
- search_replace output contract: `matches[]` with `address` (+ `has_more`/`next_offset`)
- workbook sheet fields: `sheet_name`/`is_hidden`/`*_count`, no `frozen_*`
- `+range-fill` example uses a non-overlapping target (A3:A100)
- drop the unimplemented `envelope.meta.verification` auto-readback claim; advise
  manual list/get verification instead
2026-05-27 11:56:42 +08:00
xiongyuanwen-byted
08e8b5c870 fix(sheets): allow +float-image-update to omit the image source
The image source (--image-token / --image-uri) is the only optional part
of an update: omit all of them to keep the current image. image_name,
position and size stay required — the manage_float_image tool rejects an
update without them, and +float-image-list does not return image_name to
backfill. Previously the shortcut forced an image source even when only
position/size changed, so those updates were rejected CLI-side before any
API call (reported as a Fail case in the sheets e2e rerun).

- floatImageProperties: gate the image-source requirement on create only;
  keep image_name/position/size required on both; emit image_uri only when set
- sync flag-defs.json + lark-sheets-float-image.md from sheet-skill-spec
  (image-name/position/size now required on +float-image-update)
- tests: cover the image-source-optional dry-run; the single-required checks
  move to the +batch-update sub-op path (cobra owns the standalone path)
2026-05-27 11:10:32 +08:00
zhengzhijiej-tech
338cdaa6db Merge pull request #1107 from zhengzhijiej-tech/sheets/float-image-reference-examples
docs(sheets): fix non-runnable float-image reference examples
2026-05-26 21:36:15 +08:00
zhengzhijie
55ccbc5f6a feat: 同步 tools-schema.json 改动 2026-05-26 21:34:04 +08:00
zhengzhijie
bea4c746ae docs(sheets): sync float-image reference from spec — fix non-runnable examples
Two examples in skills/lark-sheets/references/lark-sheets-float-image.md
didn't actually run against PPE; sync brings them in line with CLI behavior:

- +float-image-create local-path example missed --image-name (CLI rejects
  with `required flag(s) "image-name" not set` even when path basename
  already has the filename). Add `--image-name "logo.png"` + inline note.
- +float-image-update "only change position" example missed image source
  (CLI rejects with `one of --image, --image-token, or --image-uri is
  required`). Expand to two steps: list with --jq pulls the current
  image_token, then update re-passes --image-token to satisfy the guard.
- Leading warning realigned: image source is mandatory on every update
  call; "keep original image" still requires passing the token explicitly.

Upstream change: sheet-skill-spec MR fix/float-image-reference-examples.
2026-05-26 20:31:18 +08:00
zhengzhijiej-tech
4b16fe9ce0 Merge pull request #1098 from zhengzhijiej-tech/fix/batch-ranges-prefix-clarity
fix(sheets): +dropdown source-range warns when highlight + cells > 2000
2026-05-26 16:58:55 +08:00
zhengzhijie
f53e55ce65 fix(sheets): align +cells-search/+cells-replace option keys with server schema
The CLI emitted `options.regex` and `options.include_formulas`, but the
server-side `search_data` / `replace_data` tool schemas declare and
consume `use_regex` and `match_formulas`. Result: passing `--regex` or
`--include-formulas` always failed with `unexpected property ... is not
defined in schema`.

Keep the user-facing flag names (`--regex`, `--include-formulas`) — only
the JSON keys sent to the server change. Updates the dry-run test that
locked the wrong contract.
2026-05-26 16:13:53 +08:00
zhengzhijie
71eae77f65 docs(sheets): sync lark-sheets skill from spec — +filter-view-update --properties desc
去掉 +filter-view-update --properties 描述里"pass at least one of
--properties.rules / --range / --view-name"的误导承诺。--properties
实际是硬必填(MarkFlagRequired),且 update 走 PUT 整组覆盖语义。
2026-05-26 16:08:14 +08:00
zhengzhijie
08d025945e revert(sheets): drop tools-schema drift mirror from previous spec sync
930c9c7 顺带 sync 了 spec 的 tools-schema bundling — 跟那条 commit 一起
误带进来 chart float block required 和 data_validation.items 描述微调,
这两处其实是上游 sheet-ai-skills 还在 pending 的 revert。

配套 sheet-skill-spec 的 revert commit (a3aa9f2 on
fix/dropdown-flag-desc-real-limits / !11),重跑 sync:consumers 拉回
正确的 generated mirror:
- shortcuts/sheets/data/flag-schemas.json(chart 部分)
- skills/lark-sheets/references/lark-sheets-{chart,batch-update,write-cells}.md(rendered schema 段)

dropdown 文案改动(flag-defs.json 4 处 desc + dropdown 段的 reference
渲染)不在本 commit 范围,保持 930c9c7 的状态。
2026-05-26 15:31:40 +08:00
zhengzhijie
930c9c77a8 docs(sheets): sync lark-sheets skill from spec — dropdown flag descs reflect server reality
Pulls sheet-skill-spec canonical-spec → generated → consumers chain for
dropdown flag desc corrections committed upstream (Shortcut-flags base
table rows for +dropdown-set / +dropdown-update --options and
--source-range).

Aligns flag descs with byted-sheet behavior:
- --options: dropped fabricated "≤500 items, each ≤100 chars, no commas"
  promise. byted-sheet ListOfItemValidation enforces none of these.
- --source-range: appended note about the only real cap —
  LIST_WITH_COLOR_MAX_COUNT=2000 when --highlight is on (server flags the
  dropdown as option-error beyond that; CLI warns at Validate time per
  bb7ccae).

Also picks up an unrelated upstream tools-schema.json drift (chart float
block schema + data_validation.items description tweak) that surfaced
via npm run check:tool-schemas; bundling keeps the spec sync gate green.
2026-05-26 15:16:27 +08:00
zhengzhijie
bb7ccaedf9 fix(sheets): warn when +dropdown source-range exceeds 2000 cells with highlight on
byted-sheet's ListFromRangeValidation.checkOptionsValid() sets
isOptionError=true when shouldHighlightValidData is on and the source
range exceeds LIST_WITH_COLOR_MAX_COUNT (2000 cells) — the highlight +
large source combo is unsupported. CLI previously had no signal for
this, so users only learned by seeing the dropdown render as
option-error in the workbook.

Add a Validate-phase stderr warning in +dropdown-set and +dropdown-update
when --source-range covers >2000 cells unless --highlight=false. Soft
warning, never blocks the request. Inline --options is not subject to
this limit — server enforces no count or per-item length cap on inline
lists, so no warning fires there.
2026-05-26 14:52:58 +08:00
zhengzhijie
a0d6472e9f feat: 同步 tools-schema.json 改动 2026-05-26 14:48:12 +08:00
xiongyuanwen-byted
6b3c0b5556 docs(sheets): sync lark-sheets skill docs from upstream spec
- SKILL.md: clarify --url only resolves /sheets/ and /spreadsheets/ links; /wiki/ links must be resolved via wiki +node-get first (confirm obj_type=sheet, use obj_token)
- formula-translation: document IMPORTRANGE cross-workbook limits (max 5-level nesting, 100 refs per sheet)
- write-cells: document rich_text cells for hyperlinks, @mentions and @docs
2026-05-26 12:50:52 +08:00
zhengzhijiej-tech
f4bcb85d2e Merge pull request #1089 from zhengzhijiej-tech/fix/batch-ranges-prefix-clarity
fix(sheets): clarify batch --ranges prefix must be sheet display name
2026-05-26 11:02:11 +08:00
zhengzhijie
5880d070e2 fix(sheets): clarify batch --ranges prefix must be sheet display name
E2E test cases repeatedly trip on this:

  $ lark-cli sheets +cells-batch-set-style \
      --ranges '["7f8fba!A2:B3","7f8fba!C2:D3"]' --font-color '#3366FF' ...

  → tool "batch_update" failed: [900015206]
    sheet "7f8fba" not found. Available sheets: [{id: "7f8fba", name: "Sheet1"}]

Callers paste the hex sheet-id (e.g. "7f8fba") from a spreadsheet URL /
+sheet-create response straight into the --ranges sheet prefix. The four
batch shortcuts (+cells-batch-set-style / +cells-batch-clear /
+dropdown-update / +dropdown-delete) fan each range out into a
batch_update sub-op (set_cell_range / clear_cell_range) and pass the
prefix through as sheet_name; the server only matches sheet_name
literally, so the lookup fails.

The set_cell_range tool schema is explicit: sheet_id is the
reference_id and "must be correct or it errors"; sheet_name is the
display name. CLI can't disambiguate purely from the literal because
users can rename sheets to anything (including six-char hex strings).

Cleanest fix is at the source: each batch shortcut's --ranges flag
description now states explicitly that the prefix must be the sheet
display name and that the sheet reference_id is rejected, so agents
reading the reference don't try the id form in the first place.

No Go changes; these files are regenerated from the upstream Lark Base
Shortcut-flags table via the sheet-skill-spec sync chain.
2026-05-25 22:17:33 +08:00
zhengzhijiej-tech
2082095f18 Merge pull request #1090 from zhengzhijiej-tech/fix/dropdown-get-sheet-id-prefix
feat(cmdutil): route requests via PPE test lane via secheader
2026-05-25 22:11:25 +08:00
zhengzhijie
6cadbe807a feat: 把环境变量提交上去 2026-05-25 21:56:25 +08:00
zhengzhijiej-tech
927a73faa2 Merge pull request #1083 from zhengzhijiej-tech/fix/dropdown-get-sheet-id-prefix
fix(sheets): +dropdown-get accepts --sheet-id/--sheet-name + bare --range
2026-05-25 21:28:30 +08:00
zhengzhijie
9c447e735b fix(sheets): drop Sheet1! prefix from +cells-get / +csv-get / +csv-put flag examples
Server tools-schema.json for get_cell_ranges, get_range_as_csv and set_range_from_csv
does not accept a sheet prefix on --range / --start-cell; the sheet is selected via
--sheet-id / --sheet-name. +csv-put --start-cell also now states it must be a single
cell (no range notation).

Synced from spec repo.
2026-05-25 21:24:30 +08:00
zhengzhijie
12b94746bb fix(sheets): +dropdown-get accepts --sheet-id/--sheet-name + bare --range
Align +dropdown-get with its get_cell_ranges siblings (+cells-get / +csv-get):
sheet selection is now via --sheet-id / --sheet-name (XOR) and --range is a
bare A1 reference. The previous shape required the sheet prefix inside --range
(e.g. "Sheet1!A2:A100") and was the odd one out among the read-data wrappers;
callers pasting the sheet-id form straight from the URL hit a misleading
"sheet not found, sheetId: , sheetName: <id>" error because the prefix was
unconditionally treated as sheet_name.

Flag schema + skill reference regenerated from the upstream Lark Base
Shortcut-flags table.
2026-05-25 21:08:58 +08:00
xiongyuanwen-byted
5327e9390d feat(sheets): support local --image in +float-image-create
+float-image-create now accepts a local file via --image (XOR with
--image-token / --image-uri): the CLI uploads it as a sheet_image and
embeds the returned file_token, removing the previous "upload elsewhere
to get a token first" workaround. Path safety is checked in Validate,
--dry-run previews the extra upload step, and +batch-update rejects
--image (no upload phase). +float-image-update is unchanged (it does not
register --image).

Also syncs the lark-sheets skill docs/flag-defs from sheet-skill-spec:
the new --image flag, partial-merge / border-per-side / bare sheet-prefix
clarifications, and refreshed dropdown --colors/--highlight descriptions
(already pending in the source Base table).
2026-05-25 19:20:50 +08:00
xiongyuanwen-byted
dece428487 fix(sheets): validate +cells-set-image --image path in Validate
The unsafe-path check only ran at Execute (via FileIO.Stat), so --dry-run
printed a misleading success preview for an absolute / out-of-cwd --image
path that a real run would then reject. Move the path-safety check into
Validate (validate.SafeLocalFlagPath), so --dry-run and Execute fail
identically and both name the real --image flag. File existence stays
deferred to Execute, so legitimate relative paths still preview cleanly.

Add TestCellsSetImage_DryRunRejectsUnsafePath.
2026-05-25 19:20:33 +08:00
zhengzhijiej-tech
ff493d4534 Merge pull request #1075 from zhengzhijiej-tech/fix/sheets-e2e-fixes-batch1
fix(sheets): align +dropdown / +workbook-create / +range-sort with server + tri-state --highlight
2026-05-25 19:13:24 +08:00
zhengzhijie
ff78ff40d8 chore(sheets): sync +dropdown flag desc + write-cells narrative from spec
Mirror sheet-skill-spec generated/ into shortcuts/sheets/data/ and
skills/lark-sheets/ for the +dropdown-set / +dropdown-update path. No
hand edits in this repo.

The +dropdown flag desc and the Dropdown 配色 narrative now match the
server-side enable_highlight default flip (true) and the tri-state
--highlight semantics introduced in the sibling commit:

  * --highlight desc: 不传 = 开(按内置 10 色色板循环上色),
    --highlight=false 关闭得到纯白下拉
  * --colors desc: 单独传即生效(高亮默认开),--highlight=false 时忽略
  * write-cells reference: 三种意图三条线(默认色板 / 指定颜色 /
    纯白下拉)+ 新增 --highlight=false 示例

Source upstream: sheet-skill-spec MR !8.
2026-05-25 17:48:06 +08:00
zhengzhijie
1a2d2d04be feat(sheets): +dropdown --highlight tri-state via Changed() for opt-out
The server-side default for data_validation.enable_highlight flipped from
false to true (aligning with the UI behavior). With the previous code path

    if runtime.Bool("highlight") { dv["enable_highlight"] = true }

omitting --highlight and passing --highlight=false both produced the same
"enable_highlight key absent" body, leaving CLI users with no way to opt
out of the (now-default) highlighting.

Switch to runtime.Changed() so the translator can distinguish all three
input shapes:

  - omitted          -> no enable_highlight key (server applies default=true)
  - --highlight=true -> enable_highlight: true  (explicit no-op vs default)
  - --highlight=false -> enable_highlight: false (the only opt-out path)

flagView already exposes Changed() and mapFlagView (the +batch-update
sub-op adapter) implements it via raw-key presence — same pattern other
translators use for "Changed-only" branching (e.g. omit target_index
unless --index was set), so no interface surface change is needed.

Test coverage:
  - TestDropdownSet_HighlightTriState pins all four shapes (omit / presence
    form / explicit true / explicit false) and asserts the enable_highlight
    key's presence/value
  - TestBatchOp_BodyMatchesStandalone adds a --highlight=false sub-op case
    so the batch sub-op path produces a body byte-identical to the
    standalone +dropdown-set --highlight=false body
2026-05-25 17:40:17 +08:00
zhengzhijiej-tech
5eaa70b74a Merge pull request #1030 from zhengzhijiej-tech/fix/sheets-e2e-fixes-batch1
fix(sheets): align +workbook-create, +dropdown-*, +dim-move, +range-sort with server schema (restore --colors/--highlight)
2026-05-25 14:43:15 +08:00
zhengzhijie
f0dea38aeb fix(sheets): post-rebase test fixups after dropping superseded fix #1
Two test fallouts from rebasing onto upstream 4be06c8 (which independently
re-fixed +workbook-create and +dim-move with a more thorough approach):

- shortcuts/sheets/lark_sheet_workbook_test.go: our PR's earlier
  TestWorkbookCreate_DryRun "with headers and data → 2-step plan" subtest
  asserted the expedient sheet_name="Sheet1" / no-sheet_id wire body that
  matched our dropped fix #1 implementation. Upstream's fix #1 resolves
  the workbook's first sheet via get_workbook_structure and fills with
  the real sheet_id instead. Reset this file to upstream's version — our
  superseded assertions disappear, upstream's tests cover the new wire
  shape.

- shortcuts/sheets/execute_paths_test.go: TestExecute_RangeSort fixture
  still used the legacy {col, order} sort-key shape because the rebase
  resolution picked the upstream version of this file wholesale (it
  contained other unrelated changes). Re-apply just the one fixture
  update to {column, ascending} so fix #5's CLI-side rejection logic
  exercises a valid input — server-side sort_conditions has required
  fields `column` (string) and `ascending` (bool); the historical
  {col, order} vocabulary was never accepted.

go build ./... + go test ./shortcuts/sheets/... -count=1 both green.
2026-05-25 14:41:59 +08:00
zhengzhijie
fa503fa47f chore(sheets): restore --colors in parseJSONFlag docstring example list
The earlier commit 49104ec swapped --colors out of parseJSONFlag's "Used
by" example list when it deleted the flag (item #2 there removed --colors
/ --highlight from +dropdown-set/-update). Subsequent commits 8672d8e /
538eb2e / fb90c8b reinstated --colors (and added --source-range) but did
not roll back this docstring tweak — leaving an orphan reference to
--properties where --colors used to be.

This restores the example list to its pre-49104ec form so the docstring
matches what the helper actually services on this branch's HEAD.

Pure docstring change — function behavior unaffected, no test movement.
2026-05-25 14:39:36 +08:00
zhengzhijie
38ef6ad51e docs(sheets): sync agent-voice rewrite of Dropdown 选项+配色 from spec
Mirrors sheet-skill-spec MR !7 60df610 — narrative now describes how the
flags interact (XOR, colors length rule, highlight gating, sheet-prefix
read-back gotcha) without exposing the underlying data_validation field
names or server-side normalization details that agents don't act on.

No Go-side change, no shortcut behavior change.
2026-05-25 14:39:36 +08:00
zhengzhijie
f0d218f7ea feat(sheets): +dropdown-set/-update --source-range for listFromRange mode
Previously +dropdown-set / +dropdown-update only emitted
data_validation.type=list — agents wanting listFromRange (dropdown options
sourced from existing cells, kept in sync with that range) had to drop down
to +cells-set and hand-build a data_validation map. The flag now exposes it
natively as --source-range, paired with --options under XOR.

CLI changes:

- shortcuts/sheets/lark_sheet_write_cells.go:
  * new dropdownTypeAndItems(runtime) — central XOR resolver: rejects 0 or
    2 of {--options, --source-range}, returns (sourceSize, partial dv with
    type+items|range filled in). Source size = options length for list
    mode, rangeDimensions(--source-range) cell count for listFromRange.
  * buildDropdownValidation rewritten to call the resolver, then layer
    --colors / --multiple / --highlight on top — semantics unchanged
    for callers, just two modes instead of one.
  * validateDropdownOptions / -Colors renamed to validateDropdownSourceOrOptions
    so the XOR + length check fires at +dropdown-update Validate time too.
  * --colors length error message generalized: "must not exceed dropdown
    source size (N)" (covers both modes).
- shortcuts/sheets/lark_sheet_batch_update.go: rename call site.
- shortcuts/sheets/lark_sheet_write_cells_test.go: 4 new tests —
  ListFromRange (happy path: range + items absent + colors + highlight all
  emit), ListFromRange_ColorsLongerThanCells (overflow against T1:T3 cell
  count), XorBothSet, XorNeitherSet. Updated the existing
  ColorsLongerThanOptions assertion to match the new "source size" wording.

Spec-driven changes (synced via npm run sync:cli from sheet-skill-spec
MR !7 2c298b6):

- shortcuts/sheets/data/flag-defs.json: --options Required flips to xor on
  +dropdown-set/-update; new --source-range row gains long-form description
  pointing at server data_validation.range + the XOR semantics.
- skills/lark-sheets/references/lark-sheets-write-cells.md: "Dropdown 配色"
  section reorganized into "Dropdown 选项 + 配色" — XOR comparison table
  (list vs listFromRange), shared config flag table (--highlight /
  --colors), explicit length rule covering both modes, side-by-side
  minimal examples, server-range-normalization gotcha callout.
- skills/lark-sheets/references/lark-sheets-batch-update.md pointer updated
  to mention both modes + that +dropdown-delete is unaffected.

PPE smoke (ppe_lark_cli_sheet) on UFJxszjrZhZ1LVtc9FdcICSbn6b C column:
- +cells-set C1 → "性别" (bold + centered): updated_cells_count=1
- +dropdown-set --range C2:C21 --source-range "Sheet1!T1:T3" --colors
  '["#cce8ff","#ffd6e7","#e6e6e6"]' --highlight: updated_cells_count=20
- read-back: data_validation.type=listFromRange + range=$T$1:$T$3 (server
  normalizes the prefix away on storage; highlight_colors /
  enable_highlight not echoed by get_cell_ranges, see byted-sheet read
  projection TODO).
- error-path replay (both XOR violations + colors > source-size) all
  rejected at Validate stage with the expected messages.
2026-05-25 14:39:36 +08:00
zhengzhijie
09c02e8657 docs(sheets): sync dropdown --colors/--highlight clarification from spec
Mirrors sheet-skill-spec MR !7 changes:

- skills/lark-sheets/references/lark-sheets-write-cells.md: new "Dropdown
  配色" section explaining how --colors (→ data_validation.highlight_colors)
  and --highlight (→ data_validation.enable_highlight) compose — length
  rule (shorter ok, longer rejected), --highlight gating, palette
  fallback behavior, minimal +dropdown-set example.
- skills/lark-sheets/references/lark-sheets-batch-update.md: one-line
  pointer to the write_cells section for +dropdown-update / -delete
  (same rules).
- shortcuts/sheets/data/flag-defs.json: --colors / --highlight `desc`
  fields gain the long-form server-field / length-rule descriptions
  used by `--help`.

No Go-side change — earlier commit 538eb2e already loosened the
buildDropdownValidation length check to "must not exceed"; this PR step
just makes the docs / `--help` text catch up.
2026-05-25 14:39:36 +08:00
zhengzhijie
2ee2a59dff chore(sheets): re-sync from spec + loosen --colors length check
Catches up to sheet-skill-spec's 2026-05-25 base sync (MR !7) after
rebasing onto upstream feat/lark-sheets-refactor (12 new upstream commits
including the lark-sheets skill refactor + tools-schema migration).

Spec changes flowing in:

- highlight_colors description loosened: length may be **shorter than**
  --options (server cycles remaining slots through a built-in 10-color
  palette); previously the tool errored on any length mismatch.
- shortcuts/sheets/data/flag-schemas.json: mass re-mirror — generator now
  emits `type` before `properties` and adds explicit `additionalProperties:
  false` on object schemas (cosmetic, no behavior change).
- skills/lark-sheets/references/lark-sheets-{batch-update,chart,write-cells}.md:
  --options gains the type='list' tag; data_validation inline field-count
  goes 7 → 9 (catches up the highlight schema in the summary); chart
  position / size marked optional per upstream.

Go-side adjustment:

- buildDropdownValidation / validateDropdownOptionsColors: change the
  --colors length check from strict-equal to "must not exceed --options"
  to match the relaxed schema.
- TestDropdownSet_ColorsLengthMismatch -> TestDropdownSet_ColorsLongerThanOptions
  (now hits the overflow path with 3 colors vs 2 options).
- New TestDropdownSet_ColorsShorterAccepted: 2 colors vs 4 options is
  legal and forwarded as-is.
2026-05-25 14:39:36 +08:00
zhengzhijie
96c338735a fix(sheets): restore +dropdown --colors / --highlight, map to canonical fields
Reverses the --colors / --highlight removal from 7932ab2 (item #2 of the
batch-1 schema-alignment commit). That commit dropped both flags after the
test report flagged data_validation.colors / highlight_options as "unexpected
property" — at the time the canonical set_cell_range.data_validation schema
listed only help_text / items / operator / range / support_multiple_values /
type / values, so the flags had no server-side target and the removal was
correct.

Since then, set_cell_range.data_validation has gained two fields explicitly
modelling the dropdown highlight UI (mcp-tools.json in sheet-skill-spec
2026-05-22 base sync):

  enable_highlight  (bool)       — show pill backgrounds
  highlight_colors  (string[])   — hex pill colors, length must match items

So the flags are back, but rewired:

  --colors    -> data_validation.highlight_colors    (was: colors)
  --highlight -> data_validation.enable_highlight    (was: highlight_options)

--options -> items and --multiple -> support_multiple_values renames from
7932ab2 are kept.

Changes:

- buildDropdownValidation: re-add --colors / --highlight handling against
  the new field names; --colors length check stays inline (so dropdownSetInput
  Validate path catches it via validateViaInput, no separate guard needed).
- validateDropdownOptions -> validateDropdownOptionsColors: restore the
  Validate-time --colors length check on +dropdown-update / +dropdown-delete
  (called from lark_sheet_batch_update.go).
- TestDropdownSet_CellsShape: extend to assert highlight_colors /
  enable_highlight emitted; assert legacy `colors` / `highlight_options`
  absent.
- TestDropdownSet_ColorsLengthMismatch: new — covers the early Validate
  error path.
- TestDropdownUpdate_BatchPayload: extend to cover dropdownBatchInput
  propagation of --colors / --highlight through batch_update.
- skills/lark-sheets/references/lark-sheets-{write-cells,batch-update}.md,
  shortcuts/sheets/data/flag-defs.json, flag-schemas.json: synced from
  sheet-skill-spec generate output (MR !7).
2026-05-25 14:39:36 +08:00
zhengzhijie
101c572d64 docs(sheets): sync from sheet-skill-spec — remove dropdown --colors / --highlight
Upstream sheet-skill-spec base table deleted the --colors and --highlight
flags on +dropdown-set / +dropdown-update (the corresponding wire fields
data_validation.colors / .highlight_options were never accepted by the
server schema; see prior fix in this branch). Re-running the sync from
canonical-spec brings the CLI flag-defs and skill reference docs back in
line with the Go-side handling that already drops these fields.

Generated by `npm run sync:cli` in sheet-skill-spec @ ac7acef.
2026-05-25 14:39:36 +08:00
zhengzhijie
9d06652aa9 revert(sheets): drop direct edits to skills/lark-sheets/references/
These md files are sync targets generated from sheet-skill-spec; editing
them here gets clobbered on the next sync, same as data/flag-defs.json.
The --colors / --highlight row removals belong on the upstream base
table → canonical-spec sync, not here.

Restore the previous --colors / --highlight rows in both
lark-sheets-write-cells.md (+dropdown-set) and lark-sheets-batch-update.md
(+dropdown-update). The Go-side change in buildDropdownValidation still
drops the wire fields, so:

  - users passing --colors / --highlight today see the flag accepted
    silently (no effect on the wire) until upstream removes the flag;
  - after upstream removal + sync, both flag declarations, ref docs, and
    Go-side handling will be in sync.

Functional fixes (#1 workbook-create, #3 dropdown-get, #4 dim-move,
#5 range-sort) and dropdown wire-shape rename (#2) are unaffected.
2026-05-25 14:39:36 +08:00
zhengzhijie
5926e89ce3 revert(sheets): drop direct flag-defs.json edits — generated from spec
data/flag-defs.json is regenerated from the upstream sheet-skill-spec
canonical-spec; editing it here gets clobbered on the next sync. The
schema realignment for +dropdown-set / -update --colors / --highlight
removal needs to land on the base table first, then flow back through
sheet-skill-spec → larksuite-cli sync, not via a direct CLI-side edit.

Restore the previous flag entries verbatim. The Go-side change in
buildDropdownValidation still drops the wire fields, so:

  - users passing --colors / --highlight today see the flag accepted
    silently (no effect on the wire) until the upstream removal lands;
  - after upstream removal + sync, both the flag declarations and the
    Go-side handling will be in sync.

Functional fixes (#1 workbook-create, #3 dropdown-get, #4 dim-move,
#5 range-sort) and dropdown wire-shape rename (#2) are unaffected.
2026-05-25 14:39:36 +08:00
zhengzhijie
556b2292c7 fix(sheets): align +workbook-create, +dropdown-*, +dim-move, +range-sort with server schema
Five separate E2E failures in shortcuts/sheets/ that all trace back to a
CLI ↔ server contract mismatch. Each is independently scoped; bundling
them because they share the test-report citation and the same one-line
fix shape in most cases.

buildInitialFillInput sent {"sheet_id": ""} on the secondary
set_cell_range call after creating the workbook. The empty value was a
holdover from "...otherwise server picks first sheet" — but
set_cell_range rejects an empty selector with
"sheet_id or sheet_name is required" rather than falling back to the
default sheet.

Use sheet_name "Sheet1" instead. POST /sheets/v3/spreadsheets always
creates that sheet on workbook creation, and set_cell_range accepts
sheet_name as an equivalent selector — saves an extra
get_workbook_structure round-trip just to learn the auto-generated id.

buildDropdownValidation emitted four fields that don't exist in the
canonical set_cell_range.data_validation schema:

  - "values" (options list)       → renamed to "items"
  - "multiple_values"              → renamed to "support_multiple_values"
  - "colors" (per-option color)    → removed (not in schema; flag also
                                     removed from data/flag-defs.json
                                     for +dropdown-set / -update)
  - "highlight_options"            → removed (not in schema; flag also
                                     removed)

The canonical schema lives at sheet-skill-spec/canonical-spec/tool-
schemas/mcp-tools.json (set_cell_range tool, data_validation property);
the colors / highlight knobs were CLI inventions the server never
accepted, so removing the flags is correct (renaming would leave the
flags broken). Skill reference docs (write-cells.md, batch-update.md)
synced.

validateDropdownOptionsColors lost its colors check; renamed to
validateDropdownOptions to reflect the narrower contract.

dropdownGetInput sent "Sheet1!C2:C6" verbatim as a ranges[] entry.
get_cell_ranges expects sheet_id / sheet_name as separate fields and
ranges entries without the sheet prefix; the server bounced with
"sheet not found, sheetId:" (empty).

Use the existing splitSheetPrefixedRange helper (declared in
lark_sheet_batch_update.go) to break "Sheet1!C2:C6" into ("Sheet1",
"C2:C6"), then thread the sheet name through sheetSelectorForToolInput
exactly like +cells-get does.

The shortcut was POSTing to /sheets/v2/spreadsheets/{token}/dimension_
range, which is the v2 insert-dimension endpoint and requires a top-
level {"dimension": {...}} body. Move uses a separate endpoint:

  POST /sheets/v2/spreadsheets/{token}/move_dimension
  body: { "source": {...}, "destination_index": N }

(camelCase "destinationIndex" → snake_case "destination_index" to
match the v2 contract.) Both DryRun and Execute updated, plus the
TestDimMove_DryRun and TestExecute_DimMove assertions.

transform_range.sort_conditions[i] requires both `column` (string) and
`ascending` (bool); rangeSortInput passed the --sort-keys array through
to the server unvalidated, so missing fields surfaced as opaque
"required property X missing" errors with no per-item context.

Walk the parsed array client-side, reject with item-pointing messages.
Test fixtures and a contract-test fixture switched from the historical
{col, order} vocabulary (which the server has never accepted) to the
correct {column, ascending}.

Server-schema citations and test-report case mapping in this branch's
plan file.
2026-05-25 14:39:35 +08:00
xiongyuanwen-byted
4be06c85f6 fix(sheets): correct +workbook-create initial fill and +dim-move endpoint
+workbook-create: the v3 create response does not echo the default sheet's id, so the initial-fill set_cell_range was sent with an empty sheet_id and rejected ("sheet_id or sheet_name is required"). Resolve the workbook's first sheet via get_workbook_structure before filling.

+dim-move: the move request was POSTed to the v2 dimension_range endpoint (the add/update/delete surface, which requires a `dimension` object) and rejected with "[9499] Missing required parameter: Dimension". Switch to the native v3 move_dimension endpoint (sheet_id in path; snake_case source.{major_dimension,start_index,end_index} + destination_index). CLI --end and v3 end_index are both 0-based inclusive, so they pass through unchanged.
2026-05-25 14:15:48 +08:00
xiongyuanwen-byted
868beaf004 docs(sheets): sync lark-sheets skill spec from upstream
Refine reference docs from upstream sheet-skill-spec (core-operations,
formula-translation, visual-standards, SKILL guidance). Description-only;
no flag or CLI behavior change.
2026-05-25 11:54:43 +08:00
xiongyuanwen-byted
81bb61359d docs(sheets): sync lark-sheets skill spec from upstream
Trim and refine reference docs from upstream sheet-skill-spec
(condense core-operations workflow, tidy write-cells / range-operations /
float-image / SKILL guidance). Description-only; no flag or CLI behavior
change.
2026-05-24 14:44:17 +08:00
xiongyuanwen-byted
370137e1c3 docs(sheets): sync lark-sheets skill spec from upstream
Refine reference docs and flag-defs descriptions from upstream
sheet-skill-spec: clarify +workbook-export sheet flag scope, +filter-*
--properties optionality (omitted => empty filter on --range; rules must
be non-empty when provided), float-image reference_id wording, and
assorted reference cleanups. Description-only; existing CLI behavior
(filter passthrough, properties optional) already matches.
2026-05-24 09:55:47 +08:00
xiongyuanwen-byted
48e6072342 docs(sheets): sync chart properties schema (position/size required)
Regenerate flag-schemas.json from upstream sheet-skill-spec: the chart
properties schema now marks position and size as required, and the chart
reference doc reflects the same. flag-schemas.json is print-schema-only
(no client-side validation), so this is a generated-artifact + doc sync
with no CLI behavior change.
2026-05-23 19:09:02 +08:00
xiongyuanwen-byted
b85311c873 docs(sheets): sync lark-sheets skill spec from upstream
Refine reference docs and flag-defs descriptions from upstream
sheet-skill-spec (--depth wording for +dim-group / +dim-ungroup,
plus assorted reference clarifications). Description-only; no CLI
behavior or flag surface change.
2026-05-23 13:45:44 +08:00
xiongyuanwen-byted
2f5c625ac7 docs(sheets): sync lark-sheets read docs — --max-chars as single read cap
Sync skills/lark-sheets references from spec: drop --cell-limit / --max-rows
guidance; 大表分批读 switches to --range row windows + --max-chars auto cap + has_more.
Mirrors sheet-skill-spec 58e7456 and handler change 2befc49.
2026-05-23 09:05:55 +08:00
xiongyuanwen-byted
e0c22d6ee0 fix(sheets): make --max-chars the single read cap for +cells-get / +csv-get
Drop --cell-limit (+cells-get) and --max-rows (+csv-get) from the CLI surface
and pin the underlying tool's cell_limit / max_rows to a very large sentinel so
the tool's own defaults never truncate before --max-chars. --max-chars stays the
only knob (default 200000, unchanged).

- lark_sheet_read_data.go: add unboundedReadLimit (1e9); cellsGetInput pins
  cell_limit, csvGetInput pins max_rows; --max-chars still passed through
- data/flag-defs.json: synced from spec (drops the two flags)
- tests: spot-check moved to --max-chars; dry-run wantInput asserts cell_limit /
  max_rows are pinned high

Mirrors sheet-skill-spec (Base flag records removed).
go build ./... + go test ./shortcuts/sheets/ green.
2026-05-23 08:59:11 +08:00
xiongyuanwen-byted
efcc55460b docs(sheets): sync lark-sheets skill spec (chart/pivot wire mappings, --end semantics)
Sync skill references and flag-defs descriptions from upstream
sheet-skill-spec: clarify +chart-create properties structure
(snapshot.data), +pivot-create --target-position / --range wire-field
mappings, add a cross-command --end endpoint-semantics table
(insert/delete/hide/group exclusive vs move/resize inclusive), note
--group-state default, and rename reference identifiers to lark-sheets-*.

Description-only refinement; the existing CLI implementation already
matches the clarified wire mappings and --end semantics.
2026-05-23 01:18:25 +08:00
xiongyuanwen-byted
e003d4aa01 feat(sheets): add +cells-set --copy-to-range and sync skill spec
Sync lark-sheets skill references and flag schemas from upstream
sheet-skill-spec, and wire the newly-specced --copy-to-range flag into
+cells-set: it passes copy_to_range to the set_cell_range tool so a
template block written via --cells fans out across a larger range with
auto-shifted formula refs.
2026-05-22 18:10:45 +08:00
zhengzhijiej-tech
d91341bca3 Merge pull request #1023 from zhengzhijiej-tech/fix/sheets-audit-20260521
fix(sheets): apply lark-sheets audit 20260521 + sync spec docs
2026-05-21 22:10:47 +08:00
zhengzhijie
5a42fb5788 docs(sheets): sync lark-sheets skill from spec (audit 20260521)
Pull latest spec from sheet-skill-spec (PR ee/sheet-skill-spec!6 + earlier
develop commits) into skills/lark-sheets/ and shortcuts/sheets/data/.

Audit findings now reflected in CLI docs:

- A2 +cond-format-create example: --rule-type duplicate → duplicateValues
- A3 +cond-format-create Validate: cellValue/formula → cellIs/expression
- A5 +csv-put examples: --range → --start-cell; drop redundant --allow-overwrite
- A7 +sparkline-create: Validate / Examples aligned with real schema
  (config/sparklines), executable JSON example added
- B13 cross-doc dead links: lark_sheet_*/cli-shortcuts.md → lark-sheets-*.md
- C2 +csv-put: `=` literal warning next to Examples
- CC5 +rows-resize/+cols-resize --type auto: single point of truth in
  range-operations reference

flag-defs.json description / required sync (from base):

- A4 +float-image-update: image-name/position-*/size-* required → optional
  (patch mode)
- A8 +dim-move --start/--end description cleanup
- B3 +pivot-create --properties: data_range → source (real field name)

Also picks up the +cells-batch-clear shortcut doc (introduced in spec
develop). Go-side implementation for that shortcut is intentionally not
in this PR — docs-only preview; runtime dispatch will land in a follow-up.

`go test ./shortcuts/sheets/...` passes.
2026-05-21 20:55:29 +08:00
zhengzhijie
d914c851ac fix(sheets): require sparkline_id on +sparkline-update items (#6)
manage_sparkline_object uses two layers of IDs: --group-id picks the
sparkline group, and properties.sparklines[i].sparkline_id picks each
item inside the group. The server contract requires sparkline_id on
every update item (server maps each entry back to an existing
sparkline by this id). Agents that called +sparkline-update without
the per-item ids hit an opaque server-side rejection that didn't
mention sparkline_id at all, then got stuck in a try-fail-list-retry
loop.

Pre-check CLI-side in objectUpdateInput via a new validateUpdateInput
hook on objectCRUDSpec. sparklineSpec wires validateSparklineUpdateItems,
which walks properties.sparklines[] and rejects with a message that
points at +sparkline-list:

  +sparkline-update properties.sparklines[N] missing sparkline_id
  (run `+sparkline-list --group-id <id>` first to read sparkline_id
   for each item, then echo each id back on the corresponding update
   entry)

Scope is update-only. config-only updates (properties.config without
sparklines) stay legal — the validator skips when sparklines is
absent. Delete is not pre-checked: objectDeleteInput doesn't pass
properties through, so the partial-delete branch can't be reached
today (separate follow-up).

Tests:

- TestObjectCRUDShortcuts_DryRun: positive case for update with
  sparkline_id present.
- TestSparklineUpdate_MissingSparklineID: standalone path — error
  contains both "missing sparkline_id" and "+sparkline-list".
- TestBatchOp_RejectsBadSubOpInput: batch sub-op missing sparkline_id
  rejected with the same friendly error.

Docs synced from sheet-skill-spec (canonical change committed there):
skills/lark-sheets/references/lark-sheets-sparkline.md documents the
two-layer id model, the three "+sparkline-list first" cases, and both
delete modes.
2026-05-21 20:55:29 +08:00
xiongyuanwen-byted
300a5e8906 docs(sheets): sync stdin guidance and sparkline reference
- skills/lark-shared/SKILL.md: drop the generic "prefer stdin" section
- skills/lark-sheets/SKILL.md: add expanded stdin guidance (use stdin over @file abs paths; don't cd or write into the project dir)
- skills/lark-sheets/references/lark-sheets-sparkline.md: document the group_id / sparkline_id two-tier model with worked examples
2026-05-21 20:31:58 +08:00
xiongyuanwen-byted
5f3e8c6385 feat(sheets): add +cells-batch-clear fan-out over batch_update
Clear content/formats across many sheet-prefixed ranges in a single atomic
batch_update (one clear_cell_range op per range), mirroring the existing
+cells-batch-set-style / +dropdown-{update,delete} fan-out wrappers. The
--scope to clear_type normalization is shared with standalone +cells-clear
(normalizeClearType) so the two stay in lockstep.

high-risk-write (requires --yes); rejected as a batch sub-op like the other
fan-out wrappers. flag-defs/flag-schemas and skill docs updated to match.
2026-05-21 19:17:33 +08:00
zhengzhijiej-tech
8e8a5110ee Merge pull request #1018 from zhengzhijiej-tech/fix/sheets-validation-pushdown
fix(sheets): +batch-update sub-op CLI-side validation + cond-format / filter schema alignment
2026-05-21 17:20:15 +08:00
zhengzhijie
4e44e668f7 fix(sheets): align +cond-format / +filter with server schema (#4 + #5)
Two latent bugs in the object_crud translator surfaced during BOE smoke
testing of +batch-update. Both are schema-alignment fixes against
manage_conditional_format_object / manage_filter_object as declared in
sheet-skill-spec/canonical-spec/tool-schemas/mcp-tools.json.

#4 +cond-format: rule_type path + enum vocabulary
---------------------------------------------------
condFormatEnhance used to write the user's --rule-type value into
`properties.rule.type` (nested under a `rule` object). The server
schema actually puts it at flat `properties.rule_type` and silently
drops the nested form — so every conditional-format create/update
secretly built the wrong document.

Worse, the CLI enum exposed via flag-defs.json was its own invented
vocabulary (cellValue / formula / duplicate / unique / topBottom /
aboveBelowAverage / dataBar / colorScale / iconSet / textContains /
dateOccurring / blankCell / errorCell) — none of those values were
the strings the server accepts.

Fix:
- condFormatEnhance now writes `properties.rule_type = <value>`
  directly (no nested `rule` object).
- Synced flag-defs.json + lark-sheets-conditional-format.md enum
  vocabulary from base to match the server: duplicateValues,
  uniqueValues, cellIs, containsText, timePeriod, containsBlanks,
  notContainsBlanks, dataBar, colorScale, rank, aboveAverage,
  expression, iconSet.
- ⚠️ Breaking: scripts passing the old CLI-invented enum values
  (e.g. --rule-type cellValue) now get a cobra "invalid value …
  allowed: …" error listing the new vocabulary. No alias layer.
- TestObjectCRUDShortcuts_DryRun's +cond-format-update case updated
  to assert the flat properties.rule_type shape + new enum.

#5 +filter-{update,delete}: auto-inject filter_id = sheet_id
-------------------------------------------------------------
manage_filter_object's contract is "filter_id === sheet_id" for the
sheet-scoped filter (per per-tool description in mcp-tools.json),
and update / delete operations MUST carry filter_id. Standalone
filterUpdateInput / filterDeleteInput never set it, so the server
rejected with "filter_id is required for update/delete operation"
on every call — both standalone AND inside +batch-update.

Fix:
- filterUpdateInput / filterDeleteInput now set
  input["filter_id"] = sheetID.
- Because filter_id must equal sheet_id (not sheet_name), update /
  delete reject when only --sheet-name is given — there's no
  network lookup available inside the builder. The friendly error
  points at +workbook-info for resolving sheet-name → sheet-id.
- create still omits filter_id (server requires that — id is
  server-allocated on creation).
- New tests:
  * TestObjectCRUDShortcuts_DryRun gains a +filter-update happy-path
    case asserting filter_id is auto-injected + --range hoisting.
  * +filter-delete case updated to assert filter_id presence.
  * TestBatchOp_RejectsBadSubOpInput gains two cases asserting both
    +filter-update and +filter-delete reject --sheet-name-only with
    the friendly error.

Docs (#2 + #3 + #8) synced from sheet-skill-spec
-------------------------------------------------
Companion doc fixes that landed via npm run generate:cli + sync:cli
in sheet-skill-spec; included here because the regenerated flag-defs
and references markdown are byte-tracked in this repo:

- #2: lark-sheets-sheet-structure.md — +dim-{hide,unhide,group,
  ungroup} --start/--end desc changed from "(0-based, inclusive)" to
  "(0-based)" / "(exclusive)" to match the half-open range semantics
  the code has always implemented (requireDimRange: end > start;
  dimRange uses end - 1 for column end letters).
- #3: lark-sheets-workbook.md — +sheet-move section gains a note
  about the batch-internal requirement to pass --sheet-id AND
  --source-index explicitly (sheetMoveBatchInput's constraint).
- #8: lark-sheets-pivot-table.md — +pivot-create --properties
  example drops the stale data_range field (the actual server
  schema uses --source as a hoisted flag; properties only carries
  rows / columns / values / filters / show_*_grand_total).
2026-05-21 16:37:15 +08:00
zhengzhijie
8d0fefd9e0 fix(sheets): push +batch-update sub-op validation down into xxxInput builders
Sub-ops that omit --sheet-id (or any other required flag) used to slip
past CLI validation — Validate ran only against the standalone shortcut
path, and batchOpDispatch's translators built bodies from whatever
flagView returned, so a structurally broken sub-op surfaced as an opaque
server "sheet undefined not found" after a network round-trip.

Push each batchable shortcut's check trio down into its xxxInput builder:

  1. resolveSpreadsheetToken — stays in Validate (batch already does it
     once at the top level; sub-ops don't repeat).
  2. requireSheetSelector(sheetID, sheetName) — new helper; flagView-
     agnostic XOR + control-char check, called at the top of every
     xxxInput.
  3. shortcut-specific required / range / enum checks (--dimension,
     --range, --start <= --end, --type pixel needs --size,
     --float-image-id, image-token XOR image-uri, ...) — moved out of
     Validate into the builder body.

All ~30 batchable xxxInput builders now return (map, error). Standalone
Validate shrinks to validateViaInput(xxxInput); DryRun / Execute
propagate the error. batch_op_dispatch entries drop the noErrTranslate
wrapper and pass the builder directly — its error bubbles up wrapped
with "operations[N] (+shortcut):" context.

Tests:

- TestBatchOp_ErrorEquivalence (7 cases): XOR / logical-constraint
  errors fire identically from standalone and batch sub-op paths.
- TestBatchOp_RejectsBadSubOpInput (8 cases): cobra-required flags that
  standalone catches via MarkFlagRequired now also get rejected CLI-side
  on the batch path (where cobra is not in the loop).
- TestBatchOp_BodyMatchesStandalone (~40 cases) and
  TestBatchOp_DispatchCoversReportedBugs continue to pass — bodies stay
  byte-identical.
- BOE smoke (spreadsheet ICFwstkUGheyfptGWS2bB7RgcDf, sheet 51991c):
  +batch-update with a sub-op missing --sheet-id now returns
  "operations[0] (+dim-insert): specify at least one of --sheet-id or
  --sheet-name" before any network call.

sheetMoveBatchInput (xiongyuanwen's batch-only explicit-source-index
requirement) is preserved — it's an orthogonal batch-specific constraint
not affected by this push-down.
2026-05-21 16:37:00 +08:00
xiongyuanwen-byted
1e05e7b3ad fix(sheets): align +cells-get/+csv-get range flags with synced spec
sheet-skill-spec now declares +cells-get --range as a single string
(was string_array) and +csv-get --range as required. Match the
flag→body translators:

- +cells-get wraps the single --range into the tool's `ranges` array
  and validates with Str() instead of StrArray(), which silently
  returned nil against the now-String flag and broke the command.
- +csv-get gains a trim-based required-range guard.

Update read-data dry-run tests to single-range form and add a guard
test for the empty --range path.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
0ea7c14e4a fix(sheets): make +batch-update sub-ops reuse standalone flag→body translators
Sub-ops previously near-passed-through their input, so any shortcut whose
standalone translator renames fields broke inside a batch: +range-copy lost
range/destination_range (transform_range errored "range missing") and
+rows-resize lost range/resize_height ("No resize operation specified").

Introduce a flagView interface (satisfied by *common.RuntimeContext) and a
map-backed mapFlagView, then route every batchable sub-op through the SAME
*Input builder the standalone shortcut uses. mapFlagView seeds flag-defs.json
defaults for value reads while keeping Changed() user-driven, so a sub-op body
is byte-identical to the standalone body — locked by a batch-vs-standalone
contract test over all ~40 batchable shortcuts.

Also fix single-row/column resize: start==end now formats as "23:23" / "C:C"
(resize_range rejects a bare "23"); dimRangeFull keeps both sides while
dimRange's collapse stays for modify_sheet_structure consumers.
2026-05-21 12:42:47 +08:00
zhengzhijie
0c2e5f5e5c feat(sheets): translate +batch-update sub-ops {shortcut,input} → MCP shape
Users now hand +batch-update --operations a CLI-shape array
([{shortcut, input}, ...]) and the binary translates each sub-op to the
underlying MCP batch_update shape ({tool_name, input(+operation)}) via
a new dispatch table in shortcuts/sheets/batch_op_dispatch.go.

Dispatch table covers 50 batchable write shortcuts. Excluded by design:
- all read ops
- fan-out wrappers (+batch-update self, +cells-batch-set-style,
  +dropdown-update, +dropdown-delete) — nesting these = nested batch
- +dim-move — single shortcut uses legacy v2 /dimension_range endpoint,
  not MCP, can't be batched
- +cells-set-image — multi-step image upload, not atomic-batch friendly
- +workbook-create — new workbook, not batch-on-existing semantics

Translator also rejects sub-ops that hand-fill input.operation (implied
by shortcut name) or input.excel_id / spreadsheet_token / url (set
once at +batch-update top level).

+dim-freeze always injects operation=freeze; the count==0 unfreeze
path of the single shortcut is intentionally not supported in batch —
callers should use the single shortcut for unfreeze.

Tests cover: end-to-end translation, --continue-on-error propagation,
13 rejection cases (banned shortcuts, malformed shapes, reserved keys).

Sync'd from sheet-skill-spec: skills/lark-sheets/references/
lark-sheets-batch-update.md + shortcuts/sheets/data/flag-schemas.json
pick up the corrected enum (+cells-set-style / +dropdown-set added,
+dim-move removed).
2026-05-21 12:42:47 +08:00
zhengzhijie
9048c7097f docs(sheets): sync +batch-update CLI override schema (shortcut/input form)
Pulled from sheet-skill-spec:
- skills/lark-sheets/references/lark-sheets-batch-update.md: --operations
  now documents the {shortcut, input} form; tool_name references gone
- shortcuts/sheets/data/flag-schemas.json: --operations resolves to the
  CLI-side array<{shortcut(enum), input}> schema, sourced from spec's
  canonical-spec/tool-schemas/cli-schemas.json (cli: prefix). +dropdown
  --options also drilled one level deeper

NOTE: the binary still raw-passes --operations to MCP batch_update which
expects {tool_name, input}. A follow-up will add a shortcut→tool_name
translation layer (with per-shortcut operation field) before the docs
become actionable.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
3d3e2c7f10 refactor(sheets): build shortcut flags generically from flag-defs.json
Replace flag-descriptions.en.json with the richer flag-defs.json (full
flag definitions: type / default / enum / input / hidden / required /
kind) synced from sheet-skill-spec. Add flagsFor(command) to materialize
each shortcut's []common.Flag straight from the JSON, skipping
system-kind flags the framework injects.

Migrate every sheets shortcut (including the CRUD/list/dim/merge/
visibility factories) to Flags: flagsFor("+command"), dropping all
hand-written flag literals plus the now-dead publicTokenFlags /
publicSheetFlags / styleFlatFlags helpers and enum vars. A coverage test
locks the Go-flags-match-JSON contract.

Align Go with the new spec where they diverged: +cells-get --ranges →
--range, font-size int → float64, +filter-view-create --range now
required, +sheet-create row/col-count defaults 200/20.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
ce852e26d8 feat(shortcuts): support int64 and float64 flag types
Flag.Type previously could not express non-integer numbers. Add int64
and float64 cases to flag registration plus Int64/Float64 runtime
accessors.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
460c794f28 feat(sheets): add flag-descriptions.en.json and wire applyFlagDescs into Shortcuts()
Embed data/flag-descriptions.en.json (synced from upstream spec) and
apply it at shortcut assembly time so every Flag.Desc is sourced from
the canonical JSON rather than hardcoded Go strings. Existing hardcoded
Desc values serve as fallback for flags not yet in the JSON.

Also sync reference doc updates from upstream.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
54914e6082 docs(sheets): remove sandbox references and normalize tool names to CLI shortcuts
Replace export_sheet_to_sandbox / import_sandbox_to_sheet / doubao_code_interpreter
with local-script + batch csv-get/csv-put workflows; unify legacy MCP tool names
(set_cell_range, get_range_as_csv, etc.) to CLI shortcut format (+cells-set, +csv-get).
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
50190e8638 feat(sheets): add --print-schema runtime introspection for composite JSON flags
Composite JSON flags (--cells / --properties / --operations /
--border-styles / --sort-keys / --options) carry non-trivial structured
payloads. Reference docs cover top-level fields but agents writing
those flags often need the full JSON Schema to build a valid payload.

This adds a system-level introspection contract so any shortcut whose
flags are tracked upstream can serve its schemas locally:

  lark-cli sheets <shortcut> --print-schema --flag-name <name>
  lark-cli sheets <shortcut> --print-schema                  # list flags

The schema data is embedded at build time from a synced artifact
(shortcuts/sheets/data/flag-schemas.json). Upstream is the source of
truth — never hand-edit the JSON; update the source Base table and
rerun the sheet-skill-spec sync.

Framework changes (shortcuts/common):

- types.go: Shortcut gains an opt-in PrintFlagSchema hook
  (flagName -> bytes/error). When non-nil the framework auto-injects
  --print-schema / --flag-name and short-circuits Validate/Execute.
- runner.go: register the two system flags when PrintFlagSchema is
  set; intercept in runShortcut before identity/scope/config so
  pure-local lookups don't trigger auth or network. Install a
  PreRunE that relaxes cobra's required-flag gate when
  --print-schema is set, since asking for a schema shouldn't need
  unrelated required flags.

Sheets surface (shortcuts/sheets):

- flag_schema.go (new): go:embed data/flag-schemas.json; expose
  printFlagSchemaFor(command) closure. When flagName is empty it
  emits a JSON listing of introspectable flags for discovery;
  otherwise it returns the schema subtree as pretty JSON.
- flag_schema_test.go (new): cover embed parsing, listing /
  by-name lookup, unknown-flag error path, registration via
  Shortcuts(), and the full system-flag short-circuit through
  cobra (required flags relaxed, schema printed on stdout).
- shortcuts.go: Shortcuts() now wraps shortcutList() and attaches
  PrintFlagSchema to every command present in flag-schemas.json,
  so shortcuts opt in by being listed upstream — no per-shortcut
  boilerplate.
- data/flag-schemas.json (new, synced from sheet-skill-spec):
  19 entries, schema_version "2". Generated upstream from the Lark
  Base source-of-truth (see sheet-skill-spec
  scripts/fetch_cli_flag_schema_map.mjs); ships only per-flag
  subtrees (not the full mcp-tools.json) to keep tool internals
  out of the open-source repo.

Skill docs (skills/lark-sheets):

- SKILL.md: system-flag table gains --print-schema / --flag-name and
  an "Agent 使用提示" note steering agents to prefer --print-schema
  over guessing JSON shape from the cheatsheet.
- references/*.md: regenerated by upstream sync (Schemas-section
  boilerplate updated, plus accumulated upstream prose refinements).
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
184949ff0c refactor(sheets)!: split +dim-resize into +rows-resize and +cols-resize
Sync to upstream spec change that splits the legacy +dim-resize shortcut
into +rows-resize and +cols-resize. Reasoning is that row vs column
resize has divergent semantics (only rows support auto-fit) and the
shared --dimension flag was hiding that.

Behavior changes (BREAKING):
- +dim-resize is removed; use +rows-resize or +cols-resize.
- --dimension and --reset flags are gone.
- --type enum replaces --size/--reset:
    pixel    (requires --size)
    standard (reset to sheet default; no --size)
    auto     (auto-fit row height; +rows-resize only)
- --end is now inclusive (was exclusive). Old "--start 0 --end 5"
  (5 rows) becomes "--start 0 --end 4".
- Wire payload for resize_height / resize_width changes from
  {value: N} | {reset: true} to {type: "pixel", value: N} |
  {type: "standard"} | {type: "auto"}.

Tests cover both shortcuts across pixel / standard / auto and the
new guard surface (--type pixel needs --size; standard/auto reject
--size; +cols-resize rejects --type auto; --end < --start).

Also pulls in synced reference docs for 5 skills (batch-update,
core-operations, range-operations, sheet-structure, visual-standards)
that update prose mentions of +dim-resize.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
347d80361d fix(sheets): repair cells-set-image rich_text embed payload
The server rejected set_cell_range calls from +cells-set-image with three
distinct errors: missing "text" property, missing image_width/image_height,
and unknown attachment_token field. Realign the rich_text element to the
embed-image schema (text/image_token/image_width/image_height) and decode
PNG/JPEG/GIF dimensions from the local file before the write.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
be31975f7e refactor(sheets): align batch_update + cells-set with synced reference docs
Sync to upstream reference doc updates for 9 skills:

- batch_update sub-ops: rewrite wire fields tool/params -> tool_name/input
  in CellsBatchSetStyle and DropdownUpdate/Delete fan-out (the actual
  server contract per Schemas section); update --operations flag desc
  and tests.
- +cells-set --cells: accept bare 2D matrix [[{cell},...],...] instead
  of envelope {"cells":[[...]]}; spec example shows bare-array form.
- sparkline createDataDesc enum: win_loss -> winLoss (camelCase).

All other doc changes (float-image flat flags, cond-format
--rule-type/--ranges, pivot create-only --source/--range, filter /
filter-view extra flags, chart --properties) were already aligned in
commit ce33315.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
2acff2b17f refactor(sheets): sync shortcut flags with sheet-skill-spec v0.5.0
Upstream hoisted a batch of high-frequency scalar fields out of --data
into independent flags and renamed several composite-JSON flags to
match their semantic content. CLI catches up.

Renames (drop-in, same payload semantics):

  - +cells-replace      --replace   → --replacement
  - +cells-set          --data      → --cells
  - +workbook-create    --data      → --values
  - +batch-update       --data      → --operations (now a bare array;
                                       still accepts the envelope form for
                                       back-compat with continue_on_error)

Flat-flag hoists out of --style / --data:

  - +cells-set-style / +cells-batch-set-style
      --style JSON drops; replaced by 11 flat style flags
      (--background-color / --font-color / --font-size / --font-style /
      --font-weight / --font-line / --horizontal-alignment /
      --vertical-alignment / --word-wrap / --number-format) plus
      --border-styles for the one field that's still nested. Both
      shortcuts share styleFlatFlags() + buildCellStyleFromFlags().
  - +cells-batch-set-style also drops the [{ranges, style}] array shape
      in favor of one --ranges + the same flat style flags applied to
      all of them.

Object CRUD --data → --properties everywhere (chart / pivot / cond-format
/ filter / filter-view / sparkline / float-image). Per-skill scalar
hoists merged into properties via an enhanceCreate/UpdateInput callback:

  - +pivot-create        adds --source (required), --range
                          (and continues to expose --target-sheet-id /
                          --target-position at top level)
  - +cond-format-{create,update}
                          adds --rule-type (enum) + --ranges (JSON array);
                          merged into properties.rule.type and
                          properties.ranges respectively
  - +filter-view-{create,update}
                          adds --view-name and --range; both override
                          their properties.* counterparts
  - +filter-update        adds first-class --range (was buried in --data)

Float-image is fully hoisted — no --properties flag at all. Ten flat
flags (--image-name / --image-token | --image-uri / --position-row /
--position-col / --size-width / --size-height / --offset-row /
--offset-col / --z-index) compose the properties block. Implemented as
its own factory (newFloatImageWriteShortcut) since it diverges from the
shared CRUD spec.

Tests track every flag renamed and add explicit cases for the new flag
combos. go test -race -cover stays at 60.3 %.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
b9a1752095 refactor(sheets): inline cli-only shortcuts into their canonical skill files
Two naming cleanups:

  - lark_sheet_cli_only.go is gone. The four shortcuts it grouped
    (+workbook-create / +workbook-export / +dim-move / +cells-set-image)
    were bundled by their implementation pattern (legacy OAPI direct
    calls) rather than by canonical skill. The whole sheets package IS
    the CLI implementation, so "cli only" wasn't a meaningful grouping
    at the Go layer. Each shortcut now lives next to its skill peers:

      +workbook-create / +workbook-export → lark_sheet_workbook.go
      +dim-move                           → lark_sheet_sheet_structure.go
      +cells-set-image                    → lark_sheet_write_cells.go

    Per-skill shortcut counts now match tool-shortcut-map.json exactly
    (workbook: 11, sheet_structure: 9, write_cells: 5). Helpers
    (buildInitialFillInput, pollExportTask, downloadExportFile,
    dimMoveBody) move with their shortcuts; nothing else in the package
    referenced them.

  - testhelpers_test.go → helpers_test.go. The _test.go suffix already
    conveys "test"; the leading "test" was redundant. Matches the
    helpers.go naming convention.

Behavior unchanged. go test -race -cover stays at 60.5 %.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
96f5742511 test(sheets): cover all 70 shortcuts with dry-run + execute-path tests
Twelve _test.go files alongside the implementation, mirroring the legacy
package's coverage style:

  - testhelpers_test.go     shared rig: TestFactory + Mount + dry-run
                            capture + JSON-input decode + envelope helpers.
  - lark_sheet_*_test.go    one test file per implementation file (9
                            files), table-driven dry-run cases per shortcut
                            plus targeted validation guards.
  - execute_paths_test.go   end-to-end execute paths via httpmock stubs.
                            Covers callTool unwrap, JSON-string output
                            decoding, two-step lookup (+sheet-move),
                            batch_update fan-out, dropdown atomic writes,
                            and the legacy OAPI shortcuts (+workbook-create,
                            +dim-move) including CLI inclusive → API
                            half-open index conversion.

Test coverage on the sheets package is 60.5 % of statements with -race
clean, meeting the dev manual's ≥ 60 % patch-coverage gate.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
9898024392 feat(sheets): implement cli-only shortcuts (B8) — 70/70 complete
Land the four cli-only shortcuts that can't route through the One-OpenAPI
dispatcher (their backing capabilities aren't in mcp-tools.json):

  - +workbook-create   POST /open-apis/sheets/v3/spreadsheets
                       + optional set_cell_range follow-up that zips
                       --headers and --data into the first sheet starting
                       at A1.

  - +workbook-export   POST /open-apis/drive/v1/export_tasks (type=sheet)
                       → poll /export_tasks/:ticket up to ~30s
                       → optional GET /export_tasks/file/:file_token/download.
                       CSV mode requires --sheet-id (single sheet export).

  - +dim-move          POST /open-apis/sheets/v2/spreadsheets/:token
                                              /dimension_range
                       CLI is 0-indexed inclusive (--start / --end); the v2
                       endpoint expects half-open [startIndex, endIndex)
                       so the body uses endIndex = --end + 1. --sheet-name
                       is resolved client-side to sheet_id via
                       lookupSheetIndex when needed.

  - +cells-set-image   common.UploadDriveMediaAll
                       (parent_type=sheet_image, parent_node=token)
                       then callTool set_cell_range with cells carrying
                       rich_text: [{type:"embed-image", attachment_token, attachment_name}].
                       --range must be exactly one cell.

All four use runtime.CallAPI / DoAPI directly; only +cells-set-image
combines a legacy upload with the new One-OpenAPI for the second step
(set_cell_range is in mcp-tools.json so callTool is the right path).

This closes the migration: 70 shortcuts × 17 canonical skills × matching
the sheet-skill-spec v0.5.0 tool-shortcut-map.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
705844f312 feat(sheets): implement lark_sheet_batch_update shortcuts (B7)
Land 4 shortcuts that all funnel through the batch_update tool's atomic
operations array:

  - +batch-update            raw passthrough; --data carries the full
                             { operations: [{tool, params}, ...] } payload
                             plus optional continue_on_error. high-risk-write
                             since the caller may stuff anything inside.

  - +cells-batch-set-style   --data is [{ranges, style}, ...]; CLI flattens
                             each (entry × range) pair into a set_cell_range
                             op with a fan-out cells matrix carrying
                             cell_styles + border_styles.

  - +dropdown-update         --ranges + --options (+ --colors / --multiple /
                             --highlight) — installs/replaces one dropdown
                             across many ranges, each becoming a separate
                             set_cell_range op with data_validation in cells.

  - +dropdown-delete         --ranges — clears data_validation across many
                             ranges (high-risk-write).

Default is strict transaction: if any sub-tool fails the whole batch rolls
back. +batch-update exposes --continue-on-error to flip the policy; the
three fan-out shortcuts leave it strict (they're meant to be all-or-nothing).

Reinstates validateDropdownRanges + splitSheetPrefixedRange that were
removed during B3 → B7 relocation.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
1cbc049700 feat(sheets): implement object CRUD shortcuts (B6)
Land 21 shortcuts — three (create / update / delete) per object skill —
backed by the manage_<obj>_object tools dispatched on the operation
enum. Five standard objects (chart / cond-format / sparkline /
float-image / filter-view) share an objectCRUDSpec factory; pivot and
filter are special-cased.

Shared wire contract:
  excel_id + sheet_id|sheet_name + operation + [<obj>_id] + [properties]
CLI --data is passed through as the tool's `properties` field as-is, so
callers shape it per each object's spec doc.

Special cases:
  - pivot adds optional --target-sheet-id / --target-position on create
    (siblings of properties, not inside it).
  - cond-format exposes --rule-id (short CLI name) wired to the tool's
    conditional_format_id on the wire.
  - sparkline uses --group-id (higher-level object handle) instead of
    sparkline_id.
  - filter has no separate id flag — at most one filter per sheet, so
    filter_id is implicit. +filter-create promotes --range to a first-
    class flag (instead of burying it inside --data).
  - filter-view CRUD are `cli_status: cli-only` but
    manage_filter_view_object is in mcp-tools.json, so they go through
    callTool / One-OpenAPI alongside everything else.

All delete shortcuts are high-risk-write and require --yes.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
ee4096f141 feat(sheets): implement object-list shortcuts (B5)
Land 7 read shortcuts, one per object skill — chart / pivot table /
conditional format / filter / filter view / sparkline / float image. All
share the same shape (public sheet selector + optional <obj>-id filter)
so they're declared via newObjectListShortcut + an objectListSpec.

Notes:
  - +cond-format-list exposes --rule-id, which is renamed to
    conditional_format_id on the wire (the tool's full field name).
  - +sparkline-list exposes --group-id (the higher-level handle); the
    tool also accepts sparkline_id, intentionally not surfaced.
  - +filter-list takes no id filter — at most one sheet-level filter
    per sheet, so the listing is already unique.
  - +filter-view-list is `cli_status: cli-only` but get_filter_view_objects
    is in mcp-tools.json and dispatches through the same One-OpenAPI
    endpoint; no special path required.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
b3e99de06c feat(sheets): implement lark_sheet_range_operations shortcuts (B4)
Land 8 shortcuts across four canonical tools:

  - clear_cell_range  → +cells-clear      (high-risk-write)
  - merge_cells       → +cells-merge / +cells-unmerge
  - resize_range      → +dim-resize
  - transform_range   → +range-move / +range-copy / +range-fill / +range-sort

Three CLI↔tool vocabulary bridges live in this file:

  - +cells-clear: --scope content normalizes to the tool's clear_type
    "contents" (singular/plural spec mismatch is absorbed in the CLI).
  - +dim-resize: --size <px> wraps as resize_{height,width}:{value:N};
    --reset wraps as {reset:true}. The two flags are mutually exclusive
    and at least one is required.
  - +range-fill: CLI's five-valued --series-type collapses to the tool's
    binary fill_type — `copy` → "copyCells", anything else → "fillSeries"
    (the actual series progression is inferred server-side from the
    seed cells in --source-range).
  - +range-copy: --paste-type {values, formulas, formats} maps to the
    tool's {value_only, formula_only, format_only}; "all" omits the
    field entirely so the server applies its default.

+cells-clear is the second high-risk-write shortcut in the package;
the framework enforces --yes with exit code 10 as usual.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
0d351179e4 refactor(sheets): move +dropdown-update / +dropdown-delete to lark_sheet_batch_update
Follow-up to B3 after the spec re-mapped these two shortcuts to the
batch_update tool (atomic multi-range CRUD) instead of fan-out via
set_cell_range. Drop their Go implementations + helper validateDropdownRanges
+ splitSheetPrefixedRange from lark_sheet_write_cells.go and remove the
registrations from Shortcuts(); the shortcuts will reappear under
lark_sheet_batch_update during B7.

Also pull in the re-rendered reference docs:
  - skills/lark-sheets/references/lark-sheets-write-cells.md
  - skills/lark-sheets/references/lark-sheets-batch-update.md
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
8494534c8f feat(sheets): implement read_data / search_replace / write_cells shortcuts (B3)
Land 11 shortcuts across three canonical skills:

  - lark_sheet_read_data (3): +cells-get / +csv-get / +dropdown-get
  - lark_sheet_search_replace (2): +cells-search / +cells-replace
  - lark_sheet_write_cells (6): +cells-set / +cells-set-style / +csv-put
    / +dropdown-set / +dropdown-update / +dropdown-delete

+dropdown-get reads the data_validation field via get_cell_ranges with
the range carrying its own sheet prefix (no --sheet-id needed). The
fine-grained --include vocabulary (value / formula / style / comment /
data_validation) maps to the tool's coarse include_styles bool plus
value_render_option enum. +csv-get's --include-row-prefix=false strips
the [row=N] prefix client-side because the tool only emits the
annotated form.

+cells-search / +cells-replace flatten the tool's options sub-object
into four independent flags (--match-case / --match-entire-cell /
--regex / --include-formulas) per the flat-flag rule, then repack them on the way
in.

+cells-set takes a raw --data JSON body whose `cells` array must match
the --range dimensions. +cells-set-style fans a single --style block
out to every cell in the range via a new fillCellsMatrix helper; the
range parser (rangeDimensions / splitCellRef / letterToColumnIndex)
only accepts rectangular A1:B2 forms — whole-column / whole-row need
sheet totals and are deferred.

+dropdown-set fans the validation block out to one range; +dropdown-
update / +dropdown-delete iterate sheet-prefixed --ranges and call
set_cell_range sequentially (partial failure leaves earlier ranges
already mutated; the Tip calls this out). +dropdown-delete is
high-risk-write and requires --yes.

+cells-set-image stays deferred to the cli-only batch (needs the
shared local-file upload helper alongside +workbook-create / +dim-move
/ +workbook-export).
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
ae728fe7ec feat(sheets): implement lark_sheet_sheet_structure shortcuts (B2)
Add 8 shortcuts under the lark_sheet_sheet_structure canonical skill:
+sheet-info (get_sheet_structure) plus +dim-insert / +dim-delete /
+dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup
(modify_sheet_structure, dispatched by operation enum).

Two reusable conversion helpers cover the impedance mismatch between
the CLI surface and the tool input:

  - dimRange / dimPosition translate the CLI's 0-based exclusive-end
    range into the tool's 1-based A1 notation. row 5..8 becomes
    position "6" + count 3 (insert) or range "6:8" (range ops); column
    26..29 becomes "AA:AC".
  - infoTypeFromInclude maps the fine-grained --include vocabulary
    (row_heights / col_widths / merges / hidden_rows / hidden_cols /
    groups / frozen) to the coarse info_type enum the tool accepts;
    mixed categories collapse to "all".

+dim-delete is high-risk-write (irreversible row/column removal).
+dim-freeze --count 0 auto-dispatches to operation=unfreeze. +dim-group
accepts --depth for forward-compat with a future server-side nested
group endpoint but does not pass it through today.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
b33a06c1e4 feat(sheets): implement lark_sheet_workbook shortcuts (B1)
Land the 8 modify_workbook_structure shortcuts that round out the
lark_sheet_workbook canonical skill alongside the existing +workbook-info:
+sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy
/ +sheet-hide / +sheet-unhide / +sheet-set-tab-color. All eight call
modify_workbook_structure via the One-OpenAPI invoke_write endpoint,
dispatched by the `operation` enum.

Helpers in helpers.go grow publicSheetFlags() / resolveSheetSelector() /
sheetSelectorForToolInput() / sheetSelectorPlaceholder() so future
sheet-level shortcuts share the public --sheet-id / --sheet-name XOR
treatment. +sheet-create intentionally drops the sheet selector pair since
create has no existing-sheet anchor (matches the spec fix in
tool-shortcut-map.json).

+sheet-delete is the first high-risk-write shortcut in the canonical
package; the framework requires --yes (exit code 10 otherwise).

+sheet-move's tool requires source_index in addition to target_index. The
CLI accepts an optional --source-index override and falls back to a
single get_workbook_structure read to derive it (and to resolve sheet_id
from --sheet-name). DryRun stays network-free by rendering <resolve>
placeholders for any field that would need that read.
2026-05-21 12:42:47 +08:00
xiongyuanwen-byted
17a5f29306 refactor(sheets): rebuild lark-sheets on sheet-skill-spec canonical + One-OpenAPI
Restart lark-sheets as a spec-driven downstream. Skill content (SKILL.md
and 16 references covering 13 operations skills + 3 workflow skills,
including the standalone filter-view skill) is mirrored from the
sheet-skill-spec canonical-spec; do not hand-edit, change upstream and
rerun npm run sync:consumers.

Drop the 11 legacy shortcut sources (spreadsheet / sheet management,
cell ops, dropdown, filter-view, float image, etc.) and 10 associated
tests. Wire up the new sheet_ai/v2 One-OpenAPI single entry that
dispatches by tool_name with JSON-string input/output, and land the
first canonical shortcut +workbook-info as a template that exercises
the public token XOR pair, Risk tiering, and zero-side-effect DryRun.

sheet_ai_api.go provides callTool / invokeToolDryRun and bypasses
runtime.CallAPI's silent swallowing of non-envelope responses so
gateway and business errors from the new endpoint surface precisely.

The remaining 55 shortcuts will be designed and landed separately,
canonical skill by canonical skill.
2026-05-21 12:42:46 +08:00
105 changed files with 29652 additions and 2163 deletions

View File

@@ -117,6 +117,13 @@ 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,6 +10,7 @@ import (
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/suggest"
)
const maxSuggestions = 3
@@ -28,7 +29,7 @@ func suggestEventKeys(input string) []string {
hits = append(hits, match{def.Key, 0})
continue
}
if d := levenshtein(input, def.Key); d <= threshold {
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
hits = append(hits, match{def.Key, d})
}
}
@@ -69,34 +70,3 @@ 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,27 +10,6 @@ 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

70
cmd/flag_suggest_test.go Normal file
View File

@@ -0,0 +1,70 @@
// 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)
}
}

61
cmd/notice_test.go Normal file
View File

@@ -0,0 +1,61 @@
// 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,14 +18,17 @@ 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.
@@ -83,7 +86,15 @@ 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)
@@ -147,29 +158,49 @@ func setupNotices() {
skillscheck.Init(build.Version)
// Composed notice provider — emits keys only when each pending is set.
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",
}
output.PendingNotice = composePendingNotice
}
// composePendingNotice merges all process-level pending notices (available
// update, skills/binary drift, deprecated-command alias) into the map surfaced
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
// Extracted from Execute so the composition is unit-testable.
func composePendingNotice() map[string]interface{} {
notice := map[string]interface{}{}
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
}
if stale := skillscheck.GetPending(); stale != nil {
notice["skills"] = map[string]interface{}{
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if 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.
@@ -267,6 +298,19 @@ 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
}
@@ -308,6 +352,12 @@ 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{}
}
@@ -327,14 +377,51 @@ 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`) legitimately prints help. But an unknown
// flag placed before any subcommand (`sheets --badflag`) is whitelisted
// away by installUnknownSubcommandGuard, which also leaves args empty —
// without this check it would silently fall through to help + exit 0.
// Recover the swallowed flag tokens and fail structured so agents (and
// the flagDidYouMean contract) still see a real error.
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{
"unknown_flags": unknown,
"command_path": cmd.CommandPath(),
},
},
}
}
return cmd.Help()
}
unknown := args[0]
available := availableSubcommandNames(cmd)
available, deprecated := availableSubcommandNames(cmd)
// Rank suggestions across both current and deprecated names so a mistyped
// legacy command (e.g. +raed → +read) still resolves; the alias stays
// runnable and self-flags via the _notice on execution.
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
if len(available) > 0 {
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
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
}
return &output.ExitError{
Code: output.ExitValidation,
@@ -342,17 +429,66 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
Type: "unknown_subcommand",
Message: msg,
Hint: hint,
Detail: map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"available": available,
},
Detail: detail,
},
}
}
func availableSubcommandNames(cmd *cobra.Command) []string {
subs := make([]string, 0, len(cmd.Commands()))
// unknownFlagTokens returns the -/-- tokens in rawArgs that cmd does not define.
// installUnknownSubcommandGuard whitelists unknown flags on pure groups so a
// mistyped subcommand still reaches the suggestion path; the side effect is that
// a lone unknown flag (no subcommand) is swallowed, leaving the group to fall
// through to help. This recovers those tokens so the caller can fail structured.
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
var unknown []string
for _, a := range rawArgs {
if a == "--" {
break // everything after -- is positional
}
if len(a) < 2 || a[0] != '-' {
continue
}
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
if name != "" && !flagDefinedInTree(cmd, name) {
unknown = append(unknown, a)
}
}
return unknown
}
// flagDefinedInTree reports whether name is defined on cmd, its inherited
// (persistent) flags, or any direct subcommand. The subcommand case covers a
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
// --format is injected on every leaf shortcut, not on the group — so only a
// genuinely unknown flag like `sheets --badflag` is reported.
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
short := len(name) == 1
known := func(c *cobra.Command, inherited bool) bool {
fs := c.Flags()
if inherited {
fs = c.InheritedFlags()
}
if short {
return fs.ShorthandLookup(name) != nil
}
return fs.Lookup(name) != nil
}
if known(cmd, false) || known(cmd, true) {
return true
}
for _, c := range cmd.Commands() {
if known(c, false) {
return true
}
}
return false
}
// availableSubcommandNames returns the invokable subcommand names of cmd, split
// into current commands and backward-compatibility aliases (those tagged into
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
// sorted; hidden commands plus help/completion are omitted.
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
for _, c := range cmd.Commands() {
if c.Hidden || !c.IsAvailableCommand() {
continue
@@ -361,10 +497,90 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
if name == "help" || name == "completion" {
continue
}
subs = append(subs, name)
if cmdutil.IsDeprecatedCommand(c) {
deprecated = append(deprecated, name)
} else {
available = append(available, name)
}
}
sort.Strings(subs)
return subs
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.
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
}
// installTipsHelpFunc wraps the default help function to append a TIPS section

View File

@@ -21,6 +21,7 @@ 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"
)
@@ -268,6 +269,54 @@ 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,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -72,6 +73,60 @@ 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())
}
}
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
@@ -113,11 +168,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)
}
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")
// "+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)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
@@ -164,7 +219,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)
@@ -175,3 +230,61 @@ 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)
}
}

View File

@@ -5,6 +5,7 @@ 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
@@ -20,9 +21,9 @@ func suggestRisk(bad string) string {
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
}
best := string(candidates[0])
bestDist := levenshtein(lowered, best)
bestDist := suggest.Levenshtein(lowered, best)
for _, c := range candidates[1:] {
if d := levenshtein(lowered, string(c)); d < bestDist {
if d := suggest.Levenshtein(lowered, string(c)); d < bestDist {
bestDist, best = d, string(c)
}
}
@@ -40,47 +41,3 @@ 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,23 +29,3 @@ 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

@@ -0,0 +1,18 @@
// 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

@@ -0,0 +1,57 @@
// 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

@@ -0,0 +1,58 @@
// 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")
}
}

104
internal/suggest/suggest.go Normal file
View File

@@ -0,0 +1,104 @@
// 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

@@ -0,0 +1,74 @@
// 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

@@ -29,6 +29,7 @@ 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.
@@ -71,6 +72,16 @@ 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 }
@@ -199,6 +210,12 @@ 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)
@@ -748,6 +765,29 @@ 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)
@@ -761,6 +801,31 @@ 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
@@ -865,6 +930,16 @@ 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 {
@@ -894,7 +969,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
if err != nil {
return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err)
}
rctx.Cmd.Flags().Set(fl.Name, string(data))
// 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)))
continue
}
@@ -917,7 +994,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
if err != nil {
return FlagErrorf("--%s: %v", fl.Name, err)
}
rctx.Cmd.Flags().Set(fl.Name, string(data))
// 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)))
continue
}
}
@@ -1002,6 +1081,10 @@ 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":
@@ -1033,6 +1116,10 @@ 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 {
cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing")
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

@@ -216,3 +216,53 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
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

@@ -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" | "string_array" | "string_slice"
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
Default string // default value as string
Desc string // help text
Hidden bool // hidden from --help, still readable at runtime
@@ -58,6 +58,29 @@ 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

@@ -6,7 +6,9 @@ package shortcuts
import (
"context"
"fmt"
"os"
"slices"
"strings"
"github.com/larksuite/cli/shortcuts/okr"
"github.com/spf13/cobra"
@@ -14,6 +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/internal/registry"
"github.com/larksuite/cli/shortcuts/apps"
@@ -29,6 +32,7 @@ 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"
@@ -53,35 +57,104 @@ func IsShortcutServiceAvailable(service string, brand core.LarkBrand) bool {
return slices.Contains(allowed, brand)
}
// allShortcuts aggregates shortcuts from all domain packages.
var allShortcuts []common.Shortcut
// baseShortcuts aggregates every shortcut except sheets. Sheets are kept out of
// the global init path because their registration currently pulls in large
// embedded flag metadata; we only mount them when the current invocation is
// actually targeting sheets (or when completion/help needs the full tree).
var baseShortcuts []common.Shortcut
func init() {
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
allShortcuts = append(allShortcuts, drive.Shortcuts()...)
allShortcuts = append(allShortcuts, im.Shortcuts()...)
allShortcuts = append(allShortcuts, contact_shortcuts.Shortcuts()...)
allShortcuts = append(allShortcuts, sheets.Shortcuts()...)
allShortcuts = append(allShortcuts, base.Shortcuts()...)
allShortcuts = append(allShortcuts, event.Shortcuts()...)
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
allShortcuts = append(allShortcuts, markdown.Shortcuts()...)
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
allShortcuts = append(allShortcuts, task.Shortcuts()...)
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
allShortcuts = append(allShortcuts, okr.Shortcuts()...)
baseShortcuts = append(baseShortcuts, apps.Shortcuts()...)
baseShortcuts = append(baseShortcuts, calendar.Shortcuts()...)
baseShortcuts = append(baseShortcuts, doc.Shortcuts()...)
baseShortcuts = append(baseShortcuts, drive.Shortcuts()...)
baseShortcuts = append(baseShortcuts, im.Shortcuts()...)
baseShortcuts = append(baseShortcuts, contact_shortcuts.Shortcuts()...)
baseShortcuts = append(baseShortcuts, base.Shortcuts()...)
baseShortcuts = append(baseShortcuts, event.Shortcuts()...)
baseShortcuts = append(baseShortcuts, mail.Shortcuts()...)
baseShortcuts = append(baseShortcuts, markdown.Shortcuts()...)
baseShortcuts = append(baseShortcuts, slides.Shortcuts()...)
baseShortcuts = append(baseShortcuts, minutes.Shortcuts()...)
baseShortcuts = append(baseShortcuts, task.Shortcuts()...)
baseShortcuts = append(baseShortcuts, vc.Shortcuts()...)
baseShortcuts = append(baseShortcuts, whiteboard.Shortcuts()...)
baseShortcuts = append(baseShortcuts, wiki.Shortcuts()...)
baseShortcuts = append(baseShortcuts, okr.Shortcuts()...)
}
// AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts).
//
//go:noinline
func AllShortcuts() []common.Shortcut {
return append([]common.Shortcut(nil), allShortcuts...)
return append([]common.Shortcut(nil), allShortcuts(true)...)
}
func allShortcuts(includeSheets bool) []common.Shortcut {
out := append([]common.Shortcut(nil), baseShortcuts...)
if includeSheets {
out = append(out, 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().
out = append(out, wrapSheetsBackwardDeprecation(sheetsbackward.Shortcuts())...)
}
return out
}
func shouldIncludeSheetsShortcuts(_ *cmdutil.Factory) bool {
args := os.Args[1:]
if len(args) == 0 {
return true
}
rootNames := map[string]struct{}{
"api": {},
"auth": {},
"completion": {},
"config": {},
"doctor": {},
"event": {},
"help": {},
"profile": {},
"schema": {},
"update": {},
}
for _, sc := range baseShortcuts {
rootNames[sc.Service] = struct{}{}
}
rootNames["sheets"] = struct{}{}
rootNames["__complete"] = struct{}{}
rootNames["__completeNoDesc"] = struct{}{}
for i, arg := range args {
if strings.HasPrefix(arg, "-") {
continue
}
if arg == "help" {
for _, next := range args[i+1:] {
if strings.HasPrefix(next, "-") {
continue
}
return next == "sheets"
}
return true
}
if _, ok := rootNames[arg]; !ok {
continue
}
switch arg {
case "sheets", "completion", "__complete", "__completeNoDesc":
return true
default:
return false
}
}
// Unknown argv shape: keep the pre-change conservative behavior and mount
// the full tree rather than accidentally hiding a command.
return true
}
// RegisterShortcuts registers all +shortcut commands on the program.
@@ -100,7 +173,7 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
// Group by service
byService := make(map[string][]common.Shortcut)
for _, s := range allShortcuts {
for _, s := range allShortcuts(shouldIncludeSheetsShortcuts(f)) {
byService[s.Service] = append(byService[s.Service], s)
}
@@ -146,6 +219,9 @@ 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)
@@ -189,3 +265,153 @@ 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,6 +5,7 @@ package shortcuts
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -16,7 +17,9 @@ 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"
)
@@ -46,8 +49,15 @@ func newRegisterTestProgramWithTipsHelp() *cobra.Command {
return program
}
func withRegisterTestArgs(t *testing.T, args ...string) {
t.Helper()
orig := os.Args
os.Args = append([]string{"lark-cli"}, args...)
t.Cleanup(func() { os.Args = orig })
}
func TestAllShortcutsScopesNotNil(t *testing.T) {
for _, s := range allShortcuts {
for _, s := range AllShortcuts() {
hasScopes := s.Scopes != nil || s.UserScopes != nil || s.BotScopes != nil
if !hasScopes {
t.Errorf("shortcut %s/%s: Scopes is nil (must be explicitly set, use []string{} if no scopes needed)", s.Service, s.Command)
@@ -107,6 +117,30 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
}
}
func TestRegisterShortcuts_SkipsSheetsWhenInvocationTargetsOtherService(t *testing.T) {
program := &cobra.Command{Use: "root"}
withRegisterTestArgs(t, "auth", "status")
RegisterShortcuts(program, newRegisterTestFactory(t))
if _, _, err := program.Find([]string{"sheets", "+workbook-info"}); err == nil {
t.Fatal("unexpected sheets shortcut mounted for non-sheets invocation")
}
}
func TestRegisterShortcuts_IncludesSheetsWhenInvocationTargetsSheets(t *testing.T) {
program := &cobra.Command{Use: "root"}
withRegisterTestArgs(t, "sheets", "+workbook-info")
RegisterShortcuts(program, newRegisterTestFactory(t))
cmd, _, err := program.Find([]string{"sheets", "+workbook-info"})
if err != nil {
t.Fatalf("find sheets shortcut: %v", err)
}
if cmd == nil || cmd.Name() != "+workbook-info" {
t.Fatalf("sheets shortcut not mounted: %#v", cmd)
}
}
// Service-level cobra commands created by RegisterShortcuts must carry
// the cmdmeta.Domain annotation so plugin Selectors (platform.ByDomain)
// and Rule.Allow path-globs can resolve a command's business domain.
@@ -471,3 +505,152 @@ 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

@@ -0,0 +1,239 @@
// 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,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"context"
@@ -54,10 +54,14 @@ var SheetRead = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
readRange := runtime.Str("range")
if readRange == "" && runtime.Str("sheet-id") != "" {
if readRange == "" {
// Sheet-only selector: pass the bare sheet id through verbatim.
// Routing it via the range normalizer mangles ids that look
// A1-ish (e.g. "shtABC123" -> "shtABC123!shtABC123:shtABC123").
readRange = runtime.Str("sheet-id")
} else {
readRange = normalizePointRange(runtime.Str("sheet-id"), readRange)
}
readRange = normalizePointRange(runtime.Str("sheet-id"), readRange)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v2/spreadsheets/:token/values/:range").
Set("token", token).Set("range", readRange)
@@ -66,18 +70,19 @@ var SheetRead = common.Shortcut{
token, _ := validateSheetManageToken(runtime)
readRange := runtime.Str("range")
if readRange == "" && runtime.Str("sheet-id") != "" {
readRange = runtime.Str("sheet-id")
}
if readRange == "" {
var err error
readRange, err = getFirstSheetID(runtime, token)
if err != nil {
return err
// Sheet-only selector: keep the resolved sheet id verbatim (see DryRun).
readRange = runtime.Str("sheet-id")
if readRange == "" {
var err error
readRange, err = getFirstSheetID(runtime, token)
if err != nil {
return err
}
}
} else {
readRange = normalizePointRange(runtime.Str("sheet-id"), readRange)
}
readRange = normalizePointRange(runtime.Str("sheet-id"), readRange)
params := map[string]interface{}{}
renderOption := runtime.Str("value-render-option")
@@ -124,11 +129,14 @@ var SheetWrite = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
writeRange := runtime.Str("range")
if writeRange == "" && runtime.Str("sheet-id") != "" {
writeRange = runtime.Str("sheet-id")
}
values, _ := parseValues2DJSON(runtime.Str("values"))
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
if writeRange == "" {
// Sheet-only selector: build the write rect from the selector's
// A1 instead of treating the bare sheet id as a cell anchor.
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), "", values)
} else {
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
}
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/values").
Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": writeRange, "values": values}}).
@@ -143,18 +151,21 @@ var SheetWrite = common.Shortcut{
}
writeRange := runtime.Str("range")
if writeRange == "" && runtime.Str("sheet-id") != "" {
writeRange = runtime.Str("sheet-id")
}
if writeRange == "" {
var err error
writeRange, err = getFirstSheetID(runtime, token)
if err != nil {
return err
// Sheet-only selector: build the write rect from the selector's
// A1 (see DryRun). Resolve the first sheet when none was given.
sel := runtime.Str("sheet-id")
if sel == "" {
var err error
sel, err = getFirstSheetID(runtime, token)
if err != nil {
return err
}
}
writeRange = normalizeWriteRange(sel, "", values)
} else {
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
}
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
data, err := runtime.CallAPI("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{
"valueRange": map[string]interface{}{
@@ -200,11 +211,14 @@ var SheetAppend = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateSheetManageToken(runtime)
appendRange := runtime.Str("range")
if appendRange == "" && runtime.Str("sheet-id") != "" {
if appendRange == "" {
// Sheet-only selector: pass the bare sheet id through verbatim
// (see SheetRead.DryRun for the normalizer-mangling rationale).
appendRange = runtime.Str("sheet-id")
} else {
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
}
values, _ := parseValues2DJSON(runtime.Str("values"))
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/values_append").
Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": appendRange, "values": values}}).
@@ -219,18 +233,19 @@ var SheetAppend = common.Shortcut{
}
appendRange := runtime.Str("range")
if appendRange == "" && runtime.Str("sheet-id") != "" {
appendRange = runtime.Str("sheet-id")
}
if appendRange == "" {
var err error
appendRange, err = getFirstSheetID(runtime, token)
if err != nil {
return err
// Sheet-only selector: keep the resolved sheet id verbatim (see DryRun).
appendRange = runtime.Str("sheet-id")
if appendRange == "" {
var err error
appendRange, err = getFirstSheetID(runtime, token)
if err != nil {
return err
}
}
} else {
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
}
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
"valueRange": map[string]interface{}{

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"context"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"context"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"context"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"context"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"context"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"context"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"context"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"bytes"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"context"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"bytes"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"context"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"context"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"context"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"context"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"context"
@@ -355,6 +355,7 @@ func wrapCopySheetMoveError(err error, token, sheetID string, index int) error {
Detail: mergeSheetErrorDetail(exitErr.Detail.Detail, detail),
},
Err: err,
Raw: exitErr.Raw,
}
}

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"bytes"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"context"
@@ -126,6 +126,60 @@ func TestSheetAppendDryRunNormalizesEscapedSeparator(t *testing.T) {
}
}
// A bare sheet selector (no --range) must pass through verbatim. Sheet ids
// that look A1-ish (letters+digits) would otherwise be mangled by the range
// normalizer into "<id>!<id>:<id>".
func TestSheetReadDryRunSheetOnlyVerbatim(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "sht_test",
"range": "",
"sheet-id": "shtABC123",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetRead.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"range":"shtABC123"`) {
t.Fatalf("SheetRead.DryRun() = %s, want bare sheet id verbatim", got)
}
}
func TestSheetWriteDryRunSheetOnlyBuildsRect(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "sht_test",
"range": "",
"sheet-id": "shtABC123",
"values": `[["x"]]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetWrite.DryRun(context.Background(), runtime))
// Built from the sheet's A1 (A1:A1 for a 1x1 write), NOT the mangled
// "shtABC123!shtABC123:shtABC123" that piping a bare id through the
// range normalizer produced.
if !strings.Contains(got, `"range":"shtABC123!A1:A1"`) {
t.Fatalf("SheetWrite.DryRun() = %s, want rect built from sheet A1", got)
}
}
func TestSheetAppendDryRunSheetOnlyVerbatim(t *testing.T) {
t.Parallel()
runtime := newSheetsTestRuntime(t, map[string]string{
"spreadsheet-token": "sht_test",
"range": "",
"sheet-id": "shtABC123",
"values": `[["foo"]]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetAppend.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"range":"shtABC123"`) {
t.Fatalf("SheetAppend.DryRun() = %s, want bare sheet id verbatim", got)
}
}
func TestSheetFindDryRunNormalizesEscapedSeparator(t *testing.T) {
t.Parallel()

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"bytes"

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
package backward
import (
"context"

View File

@@ -0,0 +1,71 @@
// 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

@@ -0,0 +1,898 @@
// 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: "+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",
},
{
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

@@ -0,0 +1,342 @@
// 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},
// ─── 对象族 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

@@ -0,0 +1,83 @@
// 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

@@ -0,0 +1,578 @@
// 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

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
_ "embed"
"encoding/json"
"fmt"
"sync"
"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. We embed it and build each
// shortcut's []common.Flag from it 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 data/flag-defs.json; regenerate via the sync script.
//go:embed data/flag-defs.json
var flagDefsJSON []byte
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"`
}
var (
flagDefsOnce sync.Once
flagDefs map[string]commandDef
flagDefsErr error
)
func loadFlagDefs() (map[string]commandDef, error) {
flagDefsOnce.Do(func() {
flagDefs = make(map[string]commandDef)
if err := json.Unmarshal(flagDefsJSON, &flagDefs); err != nil {
flagDefsErr = fmt.Errorf("flag-defs.json: %w", err)
}
})
return flagDefs, flagDefsErr
}
// 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

@@ -0,0 +1,142 @@
// 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

@@ -0,0 +1,124 @@
// 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

@@ -0,0 +1,209 @@
// 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

@@ -0,0 +1,490 @@
// 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
}
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
}
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

@@ -0,0 +1,589 @@
// 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

@@ -0,0 +1,308 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"fmt"
"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", "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,51 +1,52 @@
// 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 (
"fmt"
"regexp"
"strconv"
"context"
"encoding/json"
"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 {
// 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 {
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
if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" {
if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil {
return "", common.FlagErrorf("%v", err)
}
return token, nil
}
return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet")
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
}
// extractSpreadsheetToken extracts spreadsheet token from URL.
// 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).
func extractSpreadsheetToken(input string) string {
input = strings.TrimSpace(input)
prefixes := []string{"/sheets/", "/spreadsheets/"}
for _, prefix := range prefixes {
for _, prefix := range []string{"/sheets/", "/spreadsheets/"} {
if idx := strings.Index(input, prefix); idx >= 0 {
token := input[idx+len(prefix):]
if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 {
@@ -57,183 +58,254 @@ func extractSpreadsheetToken(input string) string {
return input
}
func normalizeSheetRange(sheetID, input string) string {
input = normalizeSheetRangeSeparators(input)
if input == "" || strings.Contains(input, "!") || sheetID == "" {
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
}
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
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
}
name := strings.TrimSpace(runtime.Str("sheet-name"))
if err := validate.RejectControlChars(name, "sheet-name"); err != nil {
return "", "", common.FlagErrorf("%v", err)
}
return "", name, nil
}
// 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
}
}
// 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")
}
if sheetID != "" && sheetName != "" {
return common.FlagErrorf("--sheet-id and --sheet-name are mutually exclusive")
}
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)
}
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
// 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)
}
return singleCellRangePattern.MatchString(input) ||
cellSpanRangePattern.MatchString(input) ||
cellToColRangePattern.MatchString(input) ||
colSpanRangePattern.MatchString(input) ||
rowSpanRangePattern.MatchString(input)
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)
}
}
return nil
}
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
// 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
}
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 {
// 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 != "" {
return sheetID
}
return sheetID + "!" + anchor + ":" + endCell
return "<resolve:" + sheetName + ">"
}
func matrixDimensions(values interface{}) (rows, cols int) {
rowList, ok := values.([]interface{})
if !ok || len(rowList) == 0 {
return 1, 1
// 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
}
rows = len(rowList)
for _, row := range rowList {
if cells, ok := row.([]interface{}); ok && len(cells) > cols {
cols = len(cells)
}
var out interface{}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return nil, common.FlagErrorf("--%s: invalid JSON: %v", name, err)
}
if cols == 0 {
cols = 1
// 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
}
return rows, cols
return out, nil
}
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])
// 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)
if err != nil {
return "", err
return nil, err
}
return fmt.Sprintf("%s%d", columnIndexToName(colIndex+colOffset), rowIndex+rowOffset), nil
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
}
func columnNameToIndex(name string) int {
name = strings.ToUpper(strings.TrimSpace(name))
if name == "" {
return 0
// 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
}
index := 0
for _, r := range name {
if r < 'A' || r > 'Z' {
return 0
}
index = index*26 + int(r-'A'+1)
if v == nil {
return nil, common.FlagErrorf("--%s is required", name)
}
return index
a, ok := v.([]interface{})
if !ok {
return nil, common.FlagErrorf("--%s must be a JSON array", name)
}
return a, nil
}
func columnIndexToName(index int) string {
if index < 1 {
return ""
// ─── 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
}
var out []byte
for index > 0 {
index--
out = append([]byte{byte('A' + index%26)}, out...)
index /= 26
if v := runtime.Str("font-color"); v != "" {
style["font_color"] = v
}
return string(out)
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)")
}

View File

@@ -0,0 +1,203 @@
// 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

@@ -0,0 +1,502 @@
// 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

@@ -0,0 +1,495 @@
// 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

@@ -0,0 +1,672 @@
// 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

@@ -0,0 +1,157 @@
// 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

@@ -0,0 +1,111 @@
// 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

@@ -0,0 +1,665 @@
// 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

@@ -0,0 +1,360 @@
// 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

@@ -0,0 +1,523 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/csv"
"regexp"
"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
}
switch {
case runtime.Bool("rows-json"):
// --rows-json reshapes the CSV response into structured rows
// ({row_number, values:{col→cell}}); see assembleRowsJSON.
out = assembleRowsJSON(out, strings.TrimSpace(runtime.Str("range")))
case !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
}
// rowPrefixRe matches the leading "[row=N] " (or "[row=N],") annotation that
// the tool prepends to the first physical line of each logical CSV record.
var rowPrefixRe = regexp.MustCompile(`^\[row=(\d+)\][ ,]?`)
// assembleRowsJSON reshapes the tool's annotated_csv string into structured
// rows so callers never have to regex-parse "[row=N]" or RFC-4180 CSV by hand:
//
// {
// "range": "A1:K3380",
// "current_region": "...", // passthrough, if the tool returned it
// "rows": [{"row_number":1,"values":{"A":"姓名", ..., "K":"时间差_分钟"}},
// {"row_number":2,"values":{"A":"张三", ..., "K":"8.5"}}, ...]
// }
//
// Every logical row is emitted, including the first — no row is assumed to be a
// header, since sheet data is not always tabular. Each cell is keyed by its
// column letter (from the tool's col_indices when present, else derived from the
// requested range's start column). On any parsing trouble it returns the
// original output unchanged.
func assembleRowsJSON(out interface{}, requestedRange string) interface{} {
m, ok := out.(map[string]interface{})
if !ok {
return out
}
csvStr, ok := m["annotated_csv"].(string)
if !ok {
return out
}
// Group physical lines into logical records by [row=N] boundaries; lines
// without a prefix are embedded-newline continuations of the current record.
type logicalRow struct {
num int
text string
}
var groups []logicalRow
for _, line := range strings.Split(csvStr, "\n") {
if mm := rowPrefixRe.FindStringSubmatch(line); mm != nil {
n, _ := strconv.Atoi(mm[1])
groups = append(groups, logicalRow{num: n, text: line[len(mm[0]):]})
} else if len(groups) > 0 {
groups[len(groups)-1].text += "\n" + line
}
}
if len(groups) == 0 {
return out
}
// Parse every logical row; widest row sets the column count. No row is
// singled out as a header — that would assume the data is tabular, which it
// often is not. The model reads row 1 like any other row and decides for
// itself whether it is a header.
parsed := make([][]string, len(groups))
maxCols := 0
for i, g := range groups {
parsed[i] = parseCSVRecord(g.text)
if len(parsed[i]) > maxCols {
maxCols = len(parsed[i])
}
}
if maxCols == 0 {
return out
}
// Column letters key each cell. Prefer the tool's col_indices (authoritative,
// length == col_count); otherwise derive from the requested range's start col.
letters := coerceStringSlice(m["col_indices"])
if len(letters) < maxCols {
start := csvStartColIndex(requestedRange)
letters = make([]string, maxCols)
for j := 0; j < maxCols; j++ {
letters[j] = csvColLetter(start + j)
}
}
rows := make([]map[string]interface{}, 0, len(groups))
for i := range groups {
fields := parsed[i]
values := make(map[string]interface{}, len(letters))
for j := range letters {
v := ""
if j < len(fields) {
v = fields[j]
}
values[letters[j]] = v
}
rows = append(rows, map[string]interface{}{
"row_number": groups[i].num,
"values": values,
})
}
result := map[string]interface{}{}
for k, v := range m {
result[k] = v
}
result["range"] = requestedRange
result["rows"] = rows
// Surface the backend's "数据没读全" signal structurally instead of leaving it
// buried in warning_message prose. The tool flags it when current_region (the
// true data extent) reaches past actual_range (what was actually read) — the
// single most important anti-under-read hint. Mirror that same comparison
// (regionEndRow > actualEndRow) from the already-passthrough A1 ranges so the
// model gets the real data range as a first-class field, never having to
// parse it out of prose.
if cr, _ := m["current_region"].(string); cr != "" {
ar, _ := m["actual_range"].(string)
regionEnd := a1EndRow(cr)
readEnd := a1EndRow(ar)
if regionEnd > 0 && readEnd > 0 && regionEnd > readEnd {
result["data_not_fully_read"] = map[string]interface{}{
"read_through_row": readEnd,
"data_extends_through_row": regionEnd,
"unread_rows": regionEnd - readEnd,
"reread_range": cr,
}
}
}
// Drop the fields whose information rows-json fully carries elsewhere:
// - annotated_csv / row_indices / col_indices → reconstructed into
// columns + rows (with integer row_number), losslessly.
// - warning_message → its two halves are both handled: the static
// "[row=N] / col_indices[j]" parse nag is moot once those fields exist,
// and the dynamic "数据没读全" half is now the structured
// data_not_fully_read field above. (Confirmed against the backend's
// get-range-as-csv.ts — warning_message has no other content.)
delete(result, "annotated_csv")
delete(result, "row_indices")
delete(result, "col_indices")
delete(result, "warning_message")
return result
}
// 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
}
// parseCSVRecord parses a single logical CSV record (which may span multiple
// physical lines via quoted embedded newlines) into its fields. An empty record
// yields no fields; a malformed record falls back to a naive comma split so a
// stray quote never drops a whole row.
func parseCSVRecord(text string) []string {
if strings.TrimSpace(text) == "" {
return nil
}
r := csv.NewReader(strings.NewReader(text))
r.FieldsPerRecord = -1
fields, err := r.Read()
if err != nil {
return strings.Split(text, ",")
}
return fields
}
// coerceStringSlice returns v as []string when it is a homogeneous []interface{}
// of strings (the shape of the tool's col_indices), else nil.
func coerceStringSlice(v interface{}) []string {
arr, ok := v.([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, e := range arr {
s, ok := e.(string)
if !ok {
return nil
}
out = append(out, s)
}
return out
}
// csvStartColIndex returns the 0-based column index of a range's start column,
// e.g. "A1:K3380" → 0, "C5:F9" → 2, "Sheet1!D2" → 3. Unparseable input → 0.
func csvStartColIndex(rng string) int {
rng = strings.TrimSpace(rng)
if i := strings.LastIndex(rng, "!"); i >= 0 {
rng = rng[i+1:]
}
var letters strings.Builder
for _, c := range rng {
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') {
letters.WriteRune(c)
continue
}
break
}
if letters.Len() == 0 {
return 0
}
return csvColToIndex(letters.String())
}
// csvColToIndex converts a column letter to its 0-based index ("A"→0, "K"→10,
// "AA"→26). Non-letter input → -1.
func csvColToIndex(s string) int {
n := 0
for _, c := range strings.ToUpper(s) {
if c < 'A' || c > 'Z' {
break
}
n = n*26 + int(c-'A'+1)
}
return n - 1
}
// csvColLetter converts a 0-based column index back to its letter (0→"A",
// 25→"Z", 26→"AA"). Negative input → "".
func csvColLetter(idx int) string {
if idx < 0 {
return ""
}
var b []byte
for idx >= 0 {
b = append([]byte{byte('A' + idx%26)}, b...)
idx = idx/26 - 1
}
return string(b)
}
// 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

@@ -0,0 +1,291 @@
// 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",
},
},
{
// --rows-json is post-processing on +csv-get's response; it must
// NOT leak into the get_range_as_csv input.
name: "+csv-get --rows-json builds the same input (flag is post-process)",
sc: CsvGet,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C10", "--rows-json"},
toolName: "get_range_as_csv",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "A1:C10",
"max_rows": float64(unboundedReadLimit),
},
},
}
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"])
}
}
// TestAssembleRowsJSON covers the --rows-json reshaping: every logical row
// emitted (no header singled out), integer row_number, column-letter keyed
// values, embedded newlines inside quoted fields, and current_region passthrough.
func TestAssembleRowsJSON(t *testing.T) {
t.Parallel()
in := map[string]interface{}{
"annotated_csv": "[row=1] 姓名,备注,时间差_分钟\n[row=2] 张三,\"line1\nline2\",8.5\n[row=3] 李四,ok,3",
"current_region": "A1:C3",
"col_indices": []interface{}{"A", "B", "C"},
"row_indices": []interface{}{1, 2, 3},
"warning_message": "①定位行号…②定位列字母…",
}
out, ok := assembleRowsJSON(in, "A1:C3").(map[string]interface{})
if !ok {
t.Fatalf("assembleRowsJSON did not return a map")
}
// Fields whose info rows-json carries elsewhere are dropped (annotated_csv /
// indices → rows; warning_message → moot static nag + structured
// data_not_fully_read). Unrelated metadata like current_region is preserved.
if _, exists := out["annotated_csv"]; exists {
t.Errorf("annotated_csv should be dropped")
}
if _, exists := out["col_indices"]; exists {
t.Errorf("col_indices should be dropped")
}
if _, exists := out["warning_message"]; exists {
t.Errorf("warning_message should be dropped in rows-json mode")
}
if _, exists := out["columns"]; exists {
t.Errorf("columns field should not exist (no header assumption)")
}
if out["current_region"] != "A1:C3" {
t.Errorf("current_region passthrough lost: %v", out["current_region"])
}
rows, _ := out["rows"].([]map[string]interface{})
if len(rows) != 3 {
t.Fatalf("want all 3 rows (incl. row 1), got %d: %+v", len(rows), rows)
}
// Row 1 is emitted as a normal row, not consumed as a header.
if rows[0]["row_number"].(int) != 1 {
t.Errorf("first row_number = %v, want 1", rows[0]["row_number"])
}
if v := rows[0]["values"].(map[string]interface{}); v["A"] != "姓名" || v["C"] != "时间差_分钟" {
t.Errorf("row 1 values wrong: %+v", v)
}
// Row 2 keeps its embedded newline inside a single cell.
v1 := rows[1]["values"].(map[string]interface{})
if rows[1]["row_number"].(int) != 2 || v1["A"] != "张三" || v1["B"] != "line1\nline2" || v1["C"] != "8.5" {
t.Errorf("row 2 wrong (embedded newline?): %+v", rows[1])
}
}
// TestAssembleRowsJSON_DerivedLetters verifies cell letters are derived from the
// range start when the tool omits col_indices (e.g. a C-anchored read).
func TestAssembleRowsJSON_DerivedLetters(t *testing.T) {
t.Parallel()
in := map[string]interface{}{
"annotated_csv": "[row=5] h1,h2\n[row=6] a,b",
}
out := assembleRowsJSON(in, "C5:D6").(map[string]interface{})
rows := out["rows"].([]map[string]interface{})
if len(rows) != 2 {
t.Fatalf("want 2 rows, got %d", len(rows))
}
if rows[0]["row_number"].(int) != 5 {
t.Errorf("first row_number = %v, want 5", rows[0]["row_number"])
}
if v := rows[0]["values"].(map[string]interface{}); v["C"] != "h1" || v["D"] != "h2" {
t.Errorf("derived-letter values wrong: %+v", v)
}
if v := rows[1]["values"].(map[string]interface{}); v["C"] != "a" || v["D"] != "b" {
t.Errorf("row 6 values wrong: %+v", v)
}
}
// TestAssembleRowsJSON_DataNotFullyRead verifies the structured under-read hint:
// when current_region extends past actual_range, rows-json surfaces the true data
// range as a first-class field (mirroring the backend's prose warning).
func TestAssembleRowsJSON_DataNotFullyRead(t *testing.T) {
t.Parallel()
// Read only A1:D2, but the data region reaches D4 → 2 rows unread.
in := map[string]interface{}{
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
"actual_range": "A1:D2",
"current_region": "A1:D4",
}
out := assembleRowsJSON(in, "A1:D2").(map[string]interface{})
hint, ok := out["data_not_fully_read"].(map[string]interface{})
if !ok {
t.Fatalf("data_not_fully_read missing; out=%+v", out)
}
if hint["read_through_row"] != 2 || hint["data_extends_through_row"] != 4 ||
hint["unread_rows"] != 2 || hint["reread_range"] != "A1:D4" {
t.Errorf("data_not_fully_read wrong: %+v", hint)
}
// Fully-read case: no hint emitted.
in2 := map[string]interface{}{
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
"actual_range": "A1:D2",
"current_region": "A1:D2",
}
out2 := assembleRowsJSON(in2, "A1:D2").(map[string]interface{})
if _, exists := out2["data_not_fully_read"]; exists {
t.Errorf("data_not_fully_read should be absent when fully read")
}
}

View File

@@ -0,0 +1,172 @@
// 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

@@ -0,0 +1,102 @@
// 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

@@ -0,0 +1,679 @@
// 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

@@ -0,0 +1,342 @@
// 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

@@ -0,0 +1,439 @@
// 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": "",
},
},
}
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 checks the 2-or-3 step plan depending on
// --output-path. The order should be: POST → GET (poll) → optional GET
// (download).
func TestWorkbookExport_DryRun(t *testing.T) {
t.Parallel()
t.Run("xlsx without --output-path → 2 steps", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"})
if len(calls) != 2 {
t.Fatalf("api calls = %d, want 2 (create + poll)", len(calls))
}
create := calls[0].(map[string]interface{})
if create["url"] != "/open-apis/drive/v1/export_tasks" {
t.Errorf("first 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 → 3 steps, with sub_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) != 3 {
t.Fatalf("api calls = %d, want 3", len(calls))
}
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
if body["sub_id"] != "sh1" {
t.Errorf("csv export missing sub_id: %#v", body)
}
dl := calls[2].(map[string]interface{})
if !strings.Contains(dl["url"].(string), "/export_tasks/file/") {
t.Errorf("download url = %v", dl["url"])
}
})
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
}

View File

@@ -0,0 +1,824 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/csv"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"path/filepath"
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// ─── lark_sheet_write_cells ───────────────────────────────────────────
//
// Wraps:
// - set_cell_range (powers +cells-set / +cells-set-style /
// +dropdown-set / +dropdown-update / +dropdown-delete)
// - set_range_from_csv (powers +csv-put)
//
// +cells-set-image is a `cli_only_derivative` shortcut (needs a local file
// upload before calling set_cell_range); it lives in the cli-only batch
// where the upload helper is shared with +workbook-create / +dim-move /
// +workbook-export.
//
// All set_cell_range-backed shortcuts construct a cells matrix whose
// dimensions exactly match the target range — the tool errors on mismatch.
// CellsSet wraps set_cell_range: caller provides the cells matrix via --cells
// (JSON), with an optional --copy-to-range to replicate the written block
// across a larger area (formula refs auto-shift).
var CellsSet = common.Shortcut{
Service: "sheets",
Command: "+cells-set",
Description: "Write values / formulas / styles / comments / data validation / embed-image to a cell range.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-set"),
Validate: validateViaInput(cellsSetInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := cellsSetInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "set_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 := cellsSetInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
func cellsSetInput(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")
}
cells, err := requireJSONArray(runtime, "cells")
if err != nil {
return nil, err
}
input := map[string]interface{}{
"excel_id": token,
"range": strings.TrimSpace(runtime.Str("range")),
"cells": cells,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if !runtime.Bool("allow-overwrite") {
input["allow_overwrite"] = false
}
if copyTo := strings.TrimSpace(runtime.Str("copy-to-range")); copyTo != "" {
input["copy_to_range"] = copyTo
}
if err := validateInputAgainstSchema(runtime, input); err != nil {
return nil, err
}
return input, nil
}
// CellsSetStyle stamps a single style block across every cell in --range.
// Style is composed from a dozen flat flags (background-color, font-color,
// font-size, font-style, font-weight, font-line, horizontal-alignment,
// vertical-alignment, word-wrap, number-format) plus --border-styles for
// the only field that still needs a nested object. At least one flag must
// be set.
var CellsSetStyle = common.Shortcut{
Service: "sheets",
Command: "+cells-set-style",
Description: "Apply style flags to every cell in a range (values / formulas untouched).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-set-style"),
Validate: validateViaInput(cellsSetStyleInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := cellsSetStyleInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "set_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 := cellsSetStyleInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
if rangeStr == "" {
return nil, common.FlagErrorf("--range is required")
}
rows, cols, err := rangeDimensions(rangeStr)
if err != nil {
return nil, common.FlagErrorf("--range %q: %v", rangeStr, err)
}
if err := requireAnyStyleFlag(runtime); err != nil {
return nil, err
}
cellStyle := buildCellStyleFromFlags(runtime)
borderStyles, err := borderStylesFromFlag(runtime)
if err != nil {
return nil, err
}
cells := make([][]interface{}, rows)
for r := range cells {
row := make([]interface{}, cols)
for c := range row {
cell := map[string]interface{}{}
if len(cellStyle) > 0 {
cell["cell_styles"] = cellStyle
}
if borderStyles != nil {
cell["border_styles"] = borderStyles
}
row[c] = cell
}
cells[r] = row
}
input := map[string]interface{}{
"excel_id": token,
"range": rangeStr,
"cells": cells,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(runtime, input); err != nil {
return nil, err
}
return input, nil
}
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet, only writing
// plain values. Use +cells-set for anything richer (formula / style / note).
var CsvPut = common.Shortcut{
Service: "sheets",
Command: "+csv-put",
Description: "Paste RFC-4180 CSV into a sheet at --start-cell (plain values only, auto-expands sheet if needed).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+csv-put"), // includes the hidden --range alias (defined in the base flags table)
PostMount: func(cmd *cobra.Command) {
// --range is an accepted alias for --start-cell (see csvPutInput).
// Neither is individually required; exactly one must be set. flag-defs
// marks --start-cell required, so clear that annotation and switch to a
// one-required group — otherwise cobra rejects `--range A1` for a
// missing --start-cell before the handler ever runs.
if fl := cmd.Flags().Lookup("start-cell"); fl != nil {
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
}
cmd.MarkFlagsOneRequired("start-cell", "range")
},
Validate: validateViaInput(csvPutInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := csvPutInput(runtime, token, sheetID, sheetName)
dr := invokeToolDryRun(token, ToolKindWrite, "set_range_from_csv", input)
if rng, ok := csvPutWriteRangeFromInput(input); ok {
dr = dr.Set("writes_range", rng)
}
return dr
},
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 := csvPutInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_range_from_csv", input)
if err != nil {
return err
}
if rng, ok := csvPutWriteRangeFromInput(input); ok {
if m, isMap := out.(map[string]interface{}); isMap {
m["writes_range"] = rng
}
}
runtime.Out(out, nil)
return nil
},
}
// csvPutWriteRangeFromInput computes the rectangle +csv-put will actually write,
// from the built tool input (start_cell + csv). +csv-put pastes from the anchor
// and auto-expands to the CSV's own row/column count — the footprint is the
// result, not a user-set boundary. Surfacing it (e.g. "B2:D4") in dry-run and in
// the success envelope lets agents see how far a paste reaches before it
// silently overwrites neighbouring cells (use --allow-overwrite=false to block
// that). Returns ok=false when the anchor is not a single cell or the CSV has no
// parseable fields.
func csvPutWriteRangeFromInput(input map[string]interface{}) (string, bool) {
anchor, _ := input["start_cell"].(string)
csvText, _ := input["csv"].(string)
if anchor == "" || csvText == "" {
return "", false
}
col0, row0, ok := splitCellRef(anchor)
if !ok {
return "", false
}
r := csv.NewReader(strings.NewReader(csvText))
r.FieldsPerRecord = -1 // tolerate ragged rows; we only need the max width
records, err := r.ReadAll()
if err != nil || len(records) == 0 {
return "", false
}
cols := 0
for _, rec := range records {
if len(rec) > cols {
cols = len(rec)
}
}
if cols == 0 {
return "", false
}
endCol := columnIndexToLetter(col0 + cols - 1)
endRow := row0 + len(records) // row0 is 0-based; +len(records) is the 1-based bottom row
return fmt.Sprintf("%s:%s%d", anchor, endCol, endRow), true
}
func csvPutInput(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("csv")) == "" {
return nil, common.FlagErrorf("--csv is required")
}
anchor := strings.TrimSpace(runtime.Str("start-cell"))
// --range is accepted as an alias for --start-cell. +csv-get and +cells-set
// locate with --range, so agents routinely carry --range over to +csv-put and
// hit a guaranteed first-try failure. Honor it when --start-cell was not
// explicitly set — guard on Changed, not emptiness, because --start-cell
// defaults to "A1" and is therefore never empty. A range like "A1:H17"
// collapses to its top-left cell; +csv-put pastes from the anchor and
// auto-expands, so the range's lower-right bound is irrelevant.
//
// Standalone enforces "one of --start-cell / --range" via cobra's
// MarkFlagsOneRequired (see PostMount). A +batch-update sub-op never runs
// cobra, so without an explicit check the default "A1" silently wins and the
// paste lands at A1 instead of failing like the standalone command. Mirror
// the standalone contract: when --start-cell is absent, --range is mandatory.
if !runtime.Changed("start-cell") {
rng := strings.TrimSpace(runtime.Str("range"))
if rng == "" {
return nil, common.FlagErrorf("--start-cell or --range is required")
}
anchor = strings.TrimSpace(strings.SplitN(rng, ":", 2)[0])
}
if anchor == "" {
return nil, common.FlagErrorf("--start-cell is required")
}
if _, _, ok := splitCellRef(anchor); !ok {
return nil, common.FlagErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor)
}
input := map[string]interface{}{
"excel_id": token,
"csv": runtime.Str("csv"),
"start_cell": anchor,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if !runtime.Bool("allow-overwrite") {
input["allow_overwrite"] = false
}
return input, nil
}
// ─── +dropdown-* (set_cell_range via data_validation) ─────────────────
//
// All three dropdown shortcuts stamp a `data_validation` block on every cell
// of the target range(s). set / update / delete differ in (a) how many
// ranges they accept and (b) whether the block is populated or null.
// DropdownSet places a single dropdown on one range.
var DropdownSet = common.Shortcut{
Service: "sheets",
Command: "+dropdown-set",
Description: "Attach a dropdown / data-validation list to every cell in --range.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dropdown-set"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateViaInput(dropdownSetInput)(ctx, runtime); err != nil {
return err
}
warnDropdownSourceRangeHighlight(runtime)
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := dropdownSetInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "set_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 := dropdownSetInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
if rangeStr == "" {
return nil, common.FlagErrorf("--range is required")
}
rows, cols, err := rangeDimensions(rangeStr)
if err != nil {
return nil, common.FlagErrorf("--range %q: %v", rangeStr, err)
}
validation, err := buildDropdownValidation(runtime)
if err != nil {
return nil, err
}
cells := fillCellsMatrix(rows, cols, map[string]interface{}{"data_validation": validation})
input := map[string]interface{}{
"excel_id": token,
"range": rangeStr,
"cells": cells,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(runtime, input); err != nil {
return nil, err
}
return input, nil
}
// NOTE: +dropdown-update and +dropdown-delete were originally drafted here
// but moved to lark_sheet_batch_update (B7) per the spec: multi-range
// dropdown CRUD now goes through batch_update for atomicity. They'll land in
// the batch_update file alongside +cells-batch-set-style.
// ─── shared dropdown helpers ──────────────────────────────────────────
// buildDropdownValidation packs --options or --source-range plus --colors /
// --multiple / --highlight into the data_validation block expected by
// set_cell_range. Field names follow the canonical
// set_cell_range.data_validation schema:
//
// --options -> {type: "list", items: <strings>}
// --source-range -> {type: "listFromRange", range: <A1+sheet prefix>}
// --multiple -> support_multiple_values (bool)
// --colors -> highlight_colors (string array, hex)
// --highlight -> enable_highlight (bool, tri-state via Changed)
//
// --options and --source-range are XOR (caller must pass exactly one).
// --colors length may be shorter than the source size (options length or
// source-range cell count) — server cycles remaining slots through a
// built-in 10-color palette — but must not exceed it.
//
// --highlight is tri-state: omitted leaves enable_highlight off the body so the
// server's new default (true) applies; --highlight=true stamps an explicit true;
// --highlight=false stamps false to turn the highlight off. Using Changed() lets
// us distinguish "not passed" from "explicit false" — required because the
// server-side default flipped from false to true and a plain cobra Bool can no
// longer carry the opt-out signal.
func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) {
sourceSize, dv, err := dropdownTypeAndItems(runtime)
if err != nil {
return nil, err
}
if runtime.Str("colors") != "" {
colors, err := requireJSONArray(runtime, "colors")
if err != nil {
return nil, err
}
if len(colors) > sourceSize {
return nil, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
}
dv["highlight_colors"] = colors
}
if runtime.Bool("multiple") {
dv["support_multiple_values"] = true
}
if runtime.Changed("highlight") {
dv["enable_highlight"] = runtime.Bool("highlight")
}
return dv, nil
}
// dropdownTypeAndItems resolves the XOR between --options and --source-range
// and returns (sourceSize, partial dv with type+items|range set). sourceSize
// is the option count for `list` mode or the source-range cell count for
// `listFromRange` mode — used to validate --colors length.
func dropdownTypeAndItems(runtime flagView) (int, map[string]interface{}, error) {
optsRaw := runtime.Str("options")
sourceRange := strings.TrimSpace(runtime.Str("source-range"))
switch {
case optsRaw != "" && sourceRange != "":
return 0, nil, common.FlagErrorf("--options and --source-range are mutually exclusive; pass exactly one")
case optsRaw == "" && sourceRange == "":
return 0, nil, common.FlagErrorf("one of --options (inline list) or --source-range (listFromRange) is required")
case optsRaw != "":
options, err := requireJSONArray(runtime, "options")
if err != nil {
return 0, nil, err
}
return len(options), map[string]interface{}{
"type": "list",
"items": options,
}, nil
default: // sourceRange != ""
rows, cols, err := rangeDimensions(sourceRange)
if err != nil {
return 0, nil, common.FlagErrorf("--source-range %q: %v", sourceRange, err)
}
return rows * cols, map[string]interface{}{
"type": "listFromRange",
"range": sourceRange,
}, nil
}
}
// validateDropdownSourceOrOptions runs the XOR + --colors length check at
// Validate time so +dropdown-update / +dropdown-delete can fail fast without
// reaching the body-build step. Returns the dropdown source size (options
// length for list mode, source-range cell count for listFromRange) so
// callers can size their cells matrix.
func validateDropdownSourceOrOptions(runtime flagView) (int, error) {
sourceSize, _, err := dropdownTypeAndItems(runtime)
if err != nil {
return 0, err
}
if runtime.Str("colors") != "" {
colors, err := requireJSONArray(runtime, "colors")
if err != nil {
return 0, err
}
if len(colors) > sourceSize {
return 0, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
}
}
return sourceSize, nil
}
// dropdownSourceRangeHighlightLimit is the cell-count cap above which the
// server marks the dropdown's options as invalid when highlight is on.
// Source: byted-sheet core LIST_WITH_COLOR_MAX_COUNT
// (sheet-packages/.../dataValidation/list/ListFromRangeValidation.ts:49).
// Beyond this, ListFromRangeValidation.checkOptionsValid() sets
// isOptionError=true (highlight + range > 2000 is an unsupported combo).
const dropdownSourceRangeHighlightLimit = 2000
// warnDropdownSourceRangeHighlight emits a soft stderr warning when the user
// targets a --source-range larger than dropdownSourceRangeHighlightLimit while
// highlight is on (the server-side default and the most common path).
// Inline --options is not subject to this limit (server has no inline count
// or per-item length cap; only the listFromRange + highlight combo is).
// Validate phase only — never blocks the request. Caller must already have
// confirmed the source-or-options validation passed.
func warnDropdownSourceRangeHighlight(runtime *common.RuntimeContext) {
sourceRange := strings.TrimSpace(runtime.Str("source-range"))
if sourceRange == "" {
return // inline --options mode — no server-side size cap applies
}
// highlight is tri-state: omitted = ON (server default), --highlight=true
// = ON, --highlight=false = OFF. Only the OFF case avoids the warning.
if runtime.Changed("highlight") && !runtime.Bool("highlight") {
return
}
rows, cols, err := rangeDimensions(sourceRange)
if err != nil {
return // already errored upstream; don't double-report
}
cellCount := rows * cols
if cellCount <= dropdownSourceRangeHighlightLimit {
return
}
fmt.Fprintf(runtime.IO().ErrOut,
"warning: --source-range covers %d cells; server marks the dropdown as option-error when highlight is on and the source exceeds %d cells. Pass --highlight=false to suppress this.\n",
cellCount, dropdownSourceRangeHighlightLimit)
}
// ─── range parsing helpers ────────────────────────────────────────────
// rangeDimensions parses an A1 range like "A1:C5" / "A1" / "sheet1!B2:D10"
// and returns its row / column counts. Errors on non-rectangular forms like
// "A:C" (whole-column) or "3:6" (whole-row) — those need a row/col total
// from get_sheet_structure, outside the scope of pure local parsing.
func rangeDimensions(rangeStr string) (rows, cols int, err error) {
if idx := strings.Index(rangeStr, "!"); idx >= 0 {
rangeStr = rangeStr[idx+1:]
}
rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" {
return 0, 0, fmt.Errorf("empty range")
}
parts := strings.SplitN(rangeStr, ":", 2)
if len(parts) == 1 {
// single cell, e.g. "A1"
if _, _, ok := splitCellRef(parts[0]); !ok {
return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0])
}
return 1, 1, nil
}
startCol, startRow, ok1 := splitCellRef(parts[0])
endCol, endRow, ok2 := splitCellRef(parts[1])
if !ok1 || !ok2 {
return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr)
}
if endRow < startRow || endCol < startCol {
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0])
}
return endRow - startRow + 1, endCol - startCol + 1, nil
}
// splitCellRef parses "A1" → (col=0, row=0, true). Returns false for any
// non-rectangular form (pure column "A", pure row "1", invalid chars).
func splitCellRef(s string) (col, row int, ok bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, 0, false
}
var colEnd int
for i, r := range s {
if r >= '0' && r <= '9' {
colEnd = i
break
}
colEnd = i + 1
}
if colEnd == 0 || colEnd == len(s) {
return 0, 0, false
}
col = letterToColumnIndex(s[:colEnd])
if col < 0 {
return 0, 0, false
}
n, err := strconv.Atoi(s[colEnd:])
if err != nil || n < 1 {
return 0, 0, false
}
return col, n - 1, true
}
// letterToColumnIndex converts spreadsheet letter notation to a 0-based
// column index ("A" → 0, "Z" → 25, "AA" → 26). Returns -1 on bad input.
func letterToColumnIndex(letters string) int {
letters = strings.ToUpper(strings.TrimSpace(letters))
if letters == "" {
return -1
}
n := 0
for _, c := range letters {
if c < 'A' || c > 'Z' {
return -1
}
n = n*26 + int(c-'A'+1)
}
return n - 1
}
// fillCellsMatrix returns a rows×cols matrix where every cell is the same
// (shallow-copied) prototype map. Use for fan-out shortcuts that stamp a
// single attribute (style / data_validation) across an entire range.
func fillCellsMatrix(rows, cols int, prototype map[string]interface{}) [][]interface{} {
cells := make([][]interface{}, rows)
for r := range cells {
row := make([]interface{}, cols)
for c := range row {
cell := make(map[string]interface{}, len(prototype))
for k, v := range prototype {
cell[k] = v
}
row[c] = cell
}
cells[r] = row
}
return cells
}
// ─── +cells-set-image (cli_only_derivative) ──────────────────────────
//
// The backing tool (set_cell_range) is in mcp-tools.json, but the CLI
// shortcut also needs a local-file upload before it can call the tool.
// That extra step doesn't fit the One-OpenAPI dispatcher, so the spec
// marks this shortcut cli_only_derivative — the CLI uploads the image
// to drive (parent_type=sheet_image) and then writes the returned
// file_token into the target cell via callTool(set_cell_range) with a
// rich_text embed-image entry.
// CellsSetImage uploads a local image to drive (parent_type=sheet_image,
// parent_node=spreadsheet token) and then writes a rich_text embed-image
// into the target single-cell range via the set_cell_range tool.
var CellsSetImage = common.Shortcut{
Service: "sheets",
Command: "+cells-set-image",
Description: "Embed a local image into a single cell (uploads via drive, then set_cell_range with rich_text embed-image).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "drive:file:upload"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-set-image"),
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
}
r := strings.TrimSpace(runtime.Str("range"))
if r == "" {
return common.FlagErrorf("--range is required")
}
rows, cols, err := rangeDimensions(r)
if err != nil {
return common.FlagErrorf("--range %q: %v", r, err)
}
if rows != 1 || cols != 1 {
return common.FlagErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols)
}
imgPath := strings.TrimSpace(runtime.Str("image"))
if imgPath == "" {
return common.FlagErrorf("--image is required")
}
// Validate path safety here (not just at Execute) so --dry-run also
// rejects unsafe paths instead of giving a false-positive preview.
// SafeLocalFlagPath checks path safety only (abs/traversal/outside-cwd),
// not existence, so legitimate relative paths still dry-run cleanly;
// the Execute-time Stat below still reports a missing/unreadable file.
if _, err := validate.SafeLocalFlagPath("--image", imgPath); err != nil {
return output.ErrValidation("%s", err)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
imgPath := strings.TrimSpace(runtime.Str("image"))
fileName := strings.TrimSpace(runtime.Str("name"))
if fileName == "" {
fileName = filepath.Base(imgPath)
}
setCellBody, _ := buildToolBody("set_cell_range", map[string]interface{}{
"excel_id": token,
"range": strings.TrimSpace(runtime.Str("range")),
"sheet_id": sheetSelectorPlaceholder(sheetID, sheetName),
"cells": [][]interface{}{{map[string]interface{}{
"rich_text": []map[string]interface{}{{
"type": "embed-image",
"text": "",
"image_token": "<file_token>",
"image_width": "<image_width>",
"image_height": "<image_height>",
}},
}}},
})
return common.NewDryRunAPI().
POST("/open-apis/drive/v1/medias/upload_all").
Desc("upload local image to drive (parent_type=sheet_image)").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": "sheet_image",
"parent_node": token,
"size": "<file_size>",
"file": "@" + imgPath,
}).
POST(toolInvokePath(token, ToolKindWrite)).
Desc("embed file_token into the cell via set_cell_range").
Body(setCellBody)
},
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
}
imgPath := strings.TrimSpace(runtime.Str("image"))
fileName := strings.TrimSpace(runtime.Str("name"))
if fileName == "" {
fileName = filepath.Base(imgPath)
}
info, err := runtime.FileIO().Stat(imgPath)
if err != nil {
return common.WrapInputStatError(err)
}
imgFile, err := runtime.FileIO().Open(imgPath)
if err != nil {
return common.WrapInputStatError(err)
}
imgCfg, _, err := image.DecodeConfig(imgFile)
imgFile.Close()
if err != nil {
return fmt.Errorf("decode image dimensions: %w", err)
}
fileToken, err := common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: imgPath,
FileName: fileName,
FileSize: info.Size(),
ParentType: "sheet_image",
ParentNode: &token,
})
if err != nil {
return err
}
setCellInput := map[string]interface{}{
"excel_id": token,
"range": strings.TrimSpace(runtime.Str("range")),
"cells": [][]interface{}{{map[string]interface{}{
"rich_text": []map[string]interface{}{{
"type": "embed-image",
"text": "",
"image_token": fileToken,
"image_width": imgCfg.Width,
"image_height": imgCfg.Height,
}},
}}},
}
sheetSelectorForToolInput(setCellInput, sheetID, sheetName)
setCellOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", setCellInput)
if err != nil {
return fmt.Errorf("image uploaded (file_token=%s) but cell write failed: %w", fileToken, err)
}
runtime.Out(map[string]interface{}{
"file_token": fileToken,
"file_name": fileName,
"set_cell_range": setCellOut,
}, nil)
return nil
},
Tips: []string{
"--range must be a single cell. The uploaded image becomes a cell-internal embed; use +float-image-create for floating images.",
},
}

View File

@@ -0,0 +1,542 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
func TestWriteCellsShortcuts_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sc common.Shortcut
args []string
toolName string
wantInput map[string]interface{}
}{
{
name: "+cells-set with --cells bare 2D array",
sc: CellsSet,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:B2",
"--cells", `[[{"value":1},{"value":2}],[{"value":3},{"value":4}]]`,
},
toolName: "set_cell_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "A1:B2",
"cells": []interface{}{[]interface{}{map[string]interface{}{"value": float64(1)}, map[string]interface{}{"value": float64(2)}}, []interface{}{map[string]interface{}{"value": float64(3)}, map[string]interface{}{"value": float64(4)}}},
},
},
{
name: "+cells-set --allow-overwrite=false sends false explicitly",
sc: CellsSet,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1",
"--cells", `[[{"value":1}]]`,
"--allow-overwrite=false",
},
toolName: "set_cell_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "A1",
"cells": []interface{}{[]interface{}{map[string]interface{}{"value": float64(1)}}},
"allow_overwrite": false,
},
},
{
name: "+cells-set --copy-to-range passes copy_to_range",
sc: CellsSet,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "H2",
"--cells", `[[{"formula":"=A2*B2"}]]`,
"--copy-to-range", "H2:H100",
},
toolName: "set_cell_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "H2",
"cells": []interface{}{[]interface{}{map[string]interface{}{"formula": "=A2*B2"}}},
"copy_to_range": "H2:H100",
},
},
{
name: "+csv-put inline csv",
sc: CsvPut,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--csv", "a,b,c\n1,2,3",
"--start-cell", "B3",
},
toolName: "set_range_from_csv",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"csv": "a,b,c\n1,2,3",
"start_cell": "B3",
},
},
{
name: "+dropdown-set fans out cells matrix",
sc: DropdownSet,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A2:A4",
"--options", `["a","b"]`,
"--multiple",
},
toolName: "set_cell_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"range": "A2:A4",
},
},
}
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)
})
}
}
// TestDropdownSet_CellsShape inspects the 3×1 matrix produced from
// --range A2:A4 to confirm the data_validation prototype is replicated.
// Also covers --colors / --highlight emitting the canonical
// `highlight_colors` / `enable_highlight` field names (not the legacy
// `colors` / `highlight_options`).
func TestDropdownSet_CellsShape(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A2:A4", "--options", `["a","b"]`, "--multiple",
"--colors", `["#FFE699","#bff7d9"]`, "--highlight",
})
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
if len(cells) != 3 {
t.Fatalf("cells rows = %d, want 3 (A2:A4)", len(cells))
}
for i, row := range cells {
r, _ := row.([]interface{})
if len(r) != 1 {
t.Errorf("row %d cols = %d, want 1", i, len(r))
}
cell, _ := r[0].(map[string]interface{})
dv, _ := cell["data_validation"].(map[string]interface{})
if dv == nil {
t.Errorf("row %d cell missing data_validation: %#v", i, cell)
continue
}
if dv["type"] != "list" {
t.Errorf("row %d data_validation.type = %v, want list", i, dv["type"])
}
items, _ := dv["items"].([]interface{})
if len(items) != 2 || items[0] != "a" || items[1] != "b" {
t.Errorf("row %d data_validation.items = %#v, want [\"a\",\"b\"]", i, dv["items"])
}
if dv["support_multiple_values"] != true {
t.Errorf("row %d data_validation.support_multiple_values = %v, want true", i, dv["support_multiple_values"])
}
if _, hasLegacy := dv["multiple_values"]; hasLegacy {
t.Errorf("row %d data_validation should not emit legacy `multiple_values`", i)
}
colors, _ := dv["highlight_colors"].([]interface{})
if len(colors) != 2 || colors[0] != "#FFE699" || colors[1] != "#bff7d9" {
t.Errorf("row %d data_validation.highlight_colors = %#v, want [\"#FFE699\",\"#bff7d9\"]", i, dv["highlight_colors"])
}
if dv["enable_highlight"] != true {
t.Errorf("row %d data_validation.enable_highlight = %v, want true", i, dv["enable_highlight"])
}
if _, hasLegacy := dv["colors"]; hasLegacy {
t.Errorf("row %d data_validation should not emit legacy `colors`", i)
}
if _, hasLegacy := dv["highlight_options"]; hasLegacy {
t.Errorf("row %d data_validation should not emit legacy `highlight_options`", i)
}
}
}
// TestDropdownSet_HighlightTriState pins down the tri-state semantics of
// --highlight after the server flipped enable_highlight's default from false
// to true. The translator uses runtime.Changed() to tell "user did not pass
// the flag" apart from "user passed --highlight=false":
//
// - omitted → no enable_highlight key in body (server applies its
// new default = true)
// - --highlight → enable_highlight=true (presence-only cobra form)
// - --highlight=true → enable_highlight=true (explicit form)
// - --highlight=false → enable_highlight=false (the only way to opt out;
// the documented "plain dropdown" path)
func TestDropdownSet_HighlightTriState(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
wantKey bool
wantValue bool
}{
{
name: "omitted leaves enable_highlight off the body",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`},
wantKey: false,
},
{
name: "presence form (--highlight) stamps true",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`, "--highlight"},
wantKey: true,
wantValue: true,
},
{
name: "explicit --highlight=true stamps true",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`, "--highlight=true"},
wantKey: true,
wantValue: true,
},
{
name: "explicit --highlight=false stamps false (the opt-out path)",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`, "--highlight=false"},
wantKey: true,
wantValue: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, DropdownSet, tc.args)
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
row0, _ := cells[0].([]interface{})
cell, _ := row0[0].(map[string]interface{})
dv, _ := cell["data_validation"].(map[string]interface{})
got, has := dv["enable_highlight"]
if has != tc.wantKey {
t.Fatalf("enable_highlight key present = %v, want %v (dv = %#v)", has, tc.wantKey, dv)
}
if tc.wantKey && got != tc.wantValue {
t.Errorf("enable_highlight = %v (%T), want %v", got, got, tc.wantValue)
}
})
}
}
// TestDropdownSet_ColorsLongerThanOptions checks the early Validate-time
// error when --colors length exceeds the dropdown source size (options
// length in list mode). Equal-or-shorter lengths are accepted (server
// cycles the rest through a built-in palette).
func TestDropdownSet_ColorsLongerThanOptions(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A2:A4",
"--options", `["a","b"]`,
"--colors", `["#FFE699","#bff7d9","#ffb3b3"]`,
"--dry-run",
})
if err == nil {
t.Fatal("expected --colors length error, got nil")
}
if !strings.Contains(stderr, "must not exceed dropdown source size") && !strings.Contains(err.Error(), "must not exceed dropdown source size") {
t.Errorf("error message missing length-overflow hint:\nerr=%v\nstderr=%s", err, stderr)
}
}
// TestDropdownSet_ColorsShorterAccepted verifies the partial-colors case:
// fewer colors than options is legal — array is forwarded as-is and the
// server fills remaining slots from its default palette.
func TestDropdownSet_ColorsShorterAccepted(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A2:A4",
"--options", `["a","b","c","d"]`,
"--colors", `["#FFE699","#bff7d9"]`,
})
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
row0, _ := cells[0].([]interface{})
cell, _ := row0[0].(map[string]interface{})
dv, _ := cell["data_validation"].(map[string]interface{})
colors, _ := dv["highlight_colors"].([]interface{})
if len(colors) != 2 {
t.Errorf("highlight_colors length = %d, want 2 (forwarded as-is)", len(colors))
}
}
// TestDropdownSet_ListFromRange verifies --source-range emits
// data_validation.type=listFromRange + data_validation.range, paired with
// --colors / --highlight propagating to highlight_colors / enable_highlight.
func TestDropdownSet_ListFromRange(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "B2:B21",
"--source-range", "Sheet1!T1:T3",
"--colors", `["#cce8ff","#ffd6e7","#e6e6e6"]`,
"--highlight",
})
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
row0, _ := cells[0].([]interface{})
cell, _ := row0[0].(map[string]interface{})
dv, _ := cell["data_validation"].(map[string]interface{})
if dv["type"] != "listFromRange" {
t.Errorf("data_validation.type = %v, want listFromRange", dv["type"])
}
if dv["range"] != "Sheet1!T1:T3" {
t.Errorf("data_validation.range = %v, want Sheet1!T1:T3 (verbatim, server normalizes)", dv["range"])
}
if _, hasItems := dv["items"]; hasItems {
t.Errorf("listFromRange mode should not emit `items`: %#v", dv)
}
if dv["enable_highlight"] != true {
t.Errorf("data_validation.enable_highlight = %v, want true", dv["enable_highlight"])
}
colors, _ := dv["highlight_colors"].([]interface{})
if len(colors) != 3 {
t.Errorf("highlight_colors length = %d, want 3", len(colors))
}
}
// TestDropdownSet_ListFromRange_ColorsLongerThanCells rejects --colors
// longer than the source range cell count (T1:T3 has 3 cells, 4 colors
// must be refused).
func TestDropdownSet_ListFromRange_ColorsLongerThanCells(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "B2:B21",
"--source-range", "Sheet1!T1:T3",
"--colors", `["#a","#b","#c","#d"]`,
"--highlight",
"--dry-run",
})
if err == nil {
t.Fatal("expected --colors length error, got nil")
}
if !strings.Contains(stderr, "must not exceed dropdown source size") && !strings.Contains(err.Error(), "must not exceed dropdown source size") {
t.Errorf("error message missing source-size hint:\nerr=%v\nstderr=%s", err, stderr)
}
}
// TestDropdownSet_XorBothSet rejects passing both --options and
// --source-range.
func TestDropdownSet_XorBothSet(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "B2:B21",
"--options", `["a","b"]`,
"--source-range", "Sheet1!T1:T3",
"--dry-run",
})
if err == nil {
t.Fatal("expected XOR error, got nil")
}
if !strings.Contains(stderr, "mutually exclusive") && !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("error message missing XOR hint:\nerr=%v\nstderr=%s", err, stderr)
}
}
// TestDropdownSet_XorNeitherSet rejects passing neither --options nor
// --source-range.
func TestDropdownSet_XorNeitherSet(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "B2:B21",
"--dry-run",
})
if err == nil {
t.Fatal("expected required-one error, got nil")
}
if !strings.Contains(stderr, "one of --options") && !strings.Contains(err.Error(), "one of --options") {
t.Errorf("error message missing required-one hint:\nerr=%v\nstderr=%s", err, stderr)
}
}
// TestCellsSetStyle_FlatFlags verifies that the 11 flat style flags +
// --border-styles compose into cell_styles + border_styles per cell.
func TestCellsSetStyle_FlatFlags(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, CellsSetStyle, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:B1",
"--font-weight", "bold",
"--background-color", "#ffff00",
"--horizontal-alignment", "center",
"--border-styles", `{"top":{"style":"solid"}}`,
})
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["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" || style["horizontal_alignment"] != "center" {
t.Errorf("cell_styles wrong: %#v", style)
}
if cell["border_styles"] == nil {
t.Fatalf("border_styles missing on cell: %#v", cell)
}
if _, leaked := style["border_styles"]; leaked {
t.Errorf("border_styles leaked into cell_styles: %#v", style)
}
}
func TestCellsSetStyle_RequiresAtLeastOneFlag(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, CellsSetStyle, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:B2", "--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "at least one style flag") {
t.Errorf("expected style-flag guard; got=%s|%s|%v", stdout, stderr, err)
}
}
func TestCellsSet_RequiresJSONArray(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1", "--cells", `{"foo":"bar"}`, "--dry-run",
})
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
// Schema validator fires first now: "--cells: expected type \"array\", got \"object\"".
// Either the schema phrasing or the legacy requireJSONArray phrasing is
// acceptable — both pin the same contract.
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, `expected type "array"`) && !strings.Contains(combined, "must be a JSON array") {
t.Errorf("expected array-type guard; got=%s|%s|%v", stdout, stderr, err)
}
}
// TestCellsSetImage_DryRun verifies the 2-step plan (upload + embed) is
// rendered, including the parent_type=sheet_image upload metadata.
func TestCellsSetImage_DryRun(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, CellsSetImage, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1",
"--image", "./README.md", // any existing-shaped path; dry-run skips stat
})
if len(calls) != 2 {
t.Fatalf("api calls = %d, want 2 (upload + set_cell_range)", len(calls))
}
upload := calls[0].(map[string]interface{})
if upload["url"] != "/open-apis/drive/v1/medias/upload_all" {
t.Errorf("upload url = %v", upload["url"])
}
ubody, _ := upload["body"].(map[string]interface{})
if ubody["parent_type"] != "sheet_image" {
t.Errorf("parent_type = %v, want sheet_image", ubody["parent_type"])
}
if ubody["parent_node"] != testToken {
t.Errorf("parent_node = %v, want token", ubody["parent_node"])
}
embed := calls[1].(map[string]interface{})
body, _ := embed["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
row, _ := cells[0].([]interface{})
cell, _ := row[0].(map[string]interface{})
rt, _ := cell["rich_text"].([]interface{})
if len(rt) != 1 {
t.Fatalf("rich_text len = %d, want 1", len(rt))
}
item, _ := rt[0].(map[string]interface{})
if item["type"] != "embed-image" {
t.Errorf("rich_text.type = %v, want embed-image", item["type"])
}
if item["image_token"] != "<file_token>" {
t.Errorf("image_token = %v, want <file_token>", item["image_token"])
}
if item["text"] != "" {
t.Errorf("text = %v, want empty string", item["text"])
}
if item["image_width"] != "<image_width>" {
t.Errorf("image_width = %v, want <image_width>", item["image_width"])
}
if item["image_height"] != "<image_height>" {
t.Errorf("image_height = %v, want <image_height>", item["image_height"])
}
}
func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, CellsSetImage, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:B2", "--image", "./foo.png", "--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must be exactly one cell") {
t.Errorf("expected single-cell guard; got=%s|%s|%v", stdout, stderr, err)
}
}
// TestCellsSetImage_DryRunRejectsUnsafePath guards that an unsafe --image path
// (e.g. an absolute path) is rejected during Validate, so --dry-run fails the
// same way as a real run instead of printing a misleading success preview.
func TestCellsSetImage_DryRunRejectsUnsafePath(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, CellsSetImage, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1", "--image", "/etc/hosts", "--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must be a relative path") {
t.Errorf("expected unsafe-path guard during dry-run; got=%s|%s|%v", stdout, stderr, err)
}
}
// TestRangeDimensions exercises the A1 parser's corner cases used by
// cells-set-style / dropdown-set / rows-resize / cols-resize.
func TestRangeDimensions(t *testing.T) {
t.Parallel()
cases := []struct {
in string
wantRows int
wantCols int
wantErr bool
}{
{"A1", 1, 1, false},
{"A1:B2", 2, 2, false},
{"sheet1!C3:E10", 8, 3, false},
{"A:C", 0, 0, true}, // whole column not supported
{"3:6", 0, 0, true}, // whole row not supported
{"B2:A1", 0, 0, true}, // end before start
{"", 0, 0, true},
}
var unusedSheet common.Shortcut = CellsSet // touch the common import
_ = unusedSheet
for _, c := range cases {
rows, cols, err := rangeDimensions(c.in)
if c.wantErr {
if err == nil {
t.Errorf("rangeDimensions(%q): want error, got rows=%d cols=%d", c.in, rows, cols)
}
continue
}
if err != nil {
t.Errorf("rangeDimensions(%q) unexpected error: %v", c.in, err)
}
if rows != c.wantRows || cols != c.wantCols {
t.Errorf("rangeDimensions(%q) = (%d,%d), want (%d,%d)", c.in, rows, cols, c.wantRows, c.wantCols)
}
}
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// ToolKind selects the One-OpenAPI endpoint and its rate-limit bucket.
//
// - ToolKindRead → POST .../tools/invoke_read (scope sheets:spreadsheet:read, 10 qps)
// - ToolKindWrite → POST .../tools/invoke_write (scope sheets:spreadsheet:write_only, 5 qps)
type ToolKind string
const (
ToolKindRead ToolKind = "read"
ToolKindWrite ToolKind = "write"
)
// toolInvokePath returns the full One-OpenAPI invoke path for the given
// spreadsheet token + tool kind. Network-free, safe in DryRun.
func toolInvokePath(token string, kind ToolKind) string {
suffix := "invoke_read"
if kind == ToolKindWrite {
suffix = "invoke_write"
}
return fmt.Sprintf("/open-apis/sheet_ai/v2/spreadsheets/%s/tools/%s",
validate.EncodePathSegment(token), suffix)
}
// buildToolBody constructs the One-OpenAPI request body for a tool invocation.
// `input` is serialized to a JSON string per the API contract; callers pass
// a typed Go map and never need to handle JSON encoding themselves.
func buildToolBody(toolName string, input map[string]interface{}) (map[string]interface{}, error) {
inputJSON, err := json.Marshal(input)
if err != nil {
return nil, fmt.Errorf("encode tool input: %w", err)
}
return map[string]interface{}{
"tool_name": toolName,
"input": string(inputJSON),
}, nil
}
// callTool invokes a sheet-ai tool via the One-OpenAPI endpoint and decodes
// the JSON-string `output` field into a generic Go value (typically
// map[string]interface{}). When the tool returns an empty `output`, callTool
// returns nil with no error.
//
// kind must match the tool's read/write classification — passing a read tool
// to invoke_write (or vice versa) results in a 403 from the gateway.
func callTool(
ctx context.Context,
runtime *common.RuntimeContext,
token string,
kind ToolKind,
toolName string,
input map[string]interface{},
) (interface{}, error) {
body, err := buildToolBody(toolName, input)
if err != nil {
return nil, err
}
raw, err := runtime.RawAPI("POST", toolInvokePath(token, kind), nil, body)
if err != nil {
return nil, err
}
envelope, ok := raw.(map[string]interface{})
if !ok {
return nil, output.Errorf(output.ExitAPI, "tool_response",
"tool %q: unexpected non-JSON-object response: %v", toolName, raw)
}
code, _ := util.ToFloat64(envelope["code"])
if code != 0 {
msg, _ := envelope["msg"].(string)
return nil, output.ErrAPI(int(code), fmt.Sprintf("tool %q failed: [%d] %s", toolName, int(code), msg), envelope["error"])
}
data, _ := envelope["data"].(map[string]interface{})
rawOutput, _ := data["output"].(string)
if rawOutput == "" {
return nil, nil
}
var out interface{}
if err := json.Unmarshal([]byte(rawOutput), &out); err != nil {
return nil, output.Errorf(output.ExitAPI, "tool_output",
"tool %q returned invalid JSON output: %v", toolName, err)
}
return out, nil
}
// invokeToolDryRun renders the One-OpenAPI request the shortcut would send.
// The wire-format body (with input serialized to a JSON string) is preserved
// for fidelity, and a decoded tool_input map is surfaced alongside so humans
// don't have to mentally unmarshal the string field.
func invokeToolDryRun(
token string,
kind ToolKind,
toolName string,
input map[string]interface{},
) *common.DryRunAPI {
wireBody, _ := buildToolBody(toolName, input)
return common.NewDryRunAPI().
POST(toolInvokePath(token, kind)).
Body(wireBody).
Set("spreadsheet_token", token).
Set("tool_name", toolName).
Set("tool_input", input)
}

View File

@@ -5,67 +5,100 @@ package sheets
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all sheets shortcuts.
// Shortcuts returns all lark-sheets shortcuts. The list is grouped by
// canonical skill to mirror the sheet-skill-spec layout
// (lark_sheet_workbook → lark_sheet_float_image).
//
// Any shortcut whose command is registered in data/flag-schemas.json gets a
// PrintFlagSchema closure attached, so the framework can serve
// `--print-schema --flag-name <name>` locally.
func Shortcuts() []common.Shortcut {
all := shortcutList()
withSchema := commandsWithFlagSchema()
for i := range all {
if _, ok := withSchema[all[i].Command]; ok {
all[i].PrintFlagSchema = printFlagSchemaFor(all[i].Command)
}
}
return all
}
func shortcutList() []common.Shortcut {
return []common.Shortcut{
// Spreadsheet management
// lark_sheet_workbook
WorkbookInfo,
SheetCreate,
SheetDelete,
SheetRename,
SheetMove,
SheetCopy,
SheetHide,
SheetUnhide,
SheetSetTabColor,
WorkbookCreate,
WorkbookExport,
// lark_sheet_sheet_structure
SheetInfo,
SheetExport,
DimInsert,
DimDelete,
DimHide,
DimUnhide,
DimFreeze,
DimGroup,
DimUngroup,
DimMove,
// Sheet management
SheetCreateSheet,
SheetCopySheet,
SheetDeleteSheet,
SheetUpdateSheet,
// lark_sheet_read_data
CellsGet,
CsvGet,
DropdownGet,
// Cell data
SheetRead,
SheetWrite,
SheetAppend,
SheetFind,
SheetReplace,
// lark_sheet_search_replace
CellsSearch,
CellsReplace,
// Cell style and merge
SheetSetStyle,
SheetBatchSetStyle,
SheetMergeCells,
SheetUnmergeCells,
// lark_sheet_write_cells
CellsSet,
CellsSetStyle,
CellsSetImage,
CsvPut,
DropdownSet,
// Cell images
SheetWriteImage,
// lark_sheet_range_operations
CellsClear,
CellsMerge,
CellsUnmerge,
RowsResize,
ColsResize,
RangeMove,
RangeCopy,
RangeFill,
RangeSort,
// Row/column management
SheetAddDimension,
SheetInsertDimension,
SheetUpdateDimension,
SheetMoveDimension,
SheetDeleteDimension,
// Object list (one read shortcut per object skill)
ChartList,
PivotList,
CondFormatList,
FilterList,
FilterViewList,
SparklineList,
FloatImageList,
// Filter views
SheetCreateFilterView,
SheetUpdateFilterView,
SheetListFilterViews,
SheetGetFilterView,
SheetDeleteFilterView,
SheetCreateFilterViewCondition,
SheetUpdateFilterViewCondition,
SheetListFilterViewConditions,
SheetGetFilterViewCondition,
SheetDeleteFilterViewCondition,
// Object CRUD (3 per skill)
ChartCreate, ChartUpdate, ChartDelete,
PivotCreate, PivotUpdate, PivotDelete,
CondFormatCreate, CondFormatUpdate, CondFormatDelete,
FilterCreate, FilterUpdate, FilterDelete,
FilterViewCreate, FilterViewUpdate, FilterViewDelete,
SparklineCreate, SparklineUpdate, SparklineDelete,
FloatImageCreate, FloatImageUpdate, FloatImageDelete,
// Dropdown
SheetSetDropdown,
SheetUpdateDropdown,
SheetGetDropdown,
SheetDeleteDropdown,
// Float images
SheetMediaUpload,
SheetCreateFloatImage,
SheetUpdateFloatImage,
SheetGetFloatImage,
SheetListFloatImages,
SheetDeleteFloatImage,
// lark_sheet_batch_update
BatchUpdate,
CellsBatchSetStyle,
CellsBatchClear,
DropdownUpdate,
DropdownDelete,
}
}

View File

@@ -1,343 +1,156 @@
---
name: lark-sheets
version: 1.2.0
description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
version: 2.0.0
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。仅针对飞书在线电子表格,不适用于本地 Excel 文件。"
metadata:
requires:
bins: ["lark-cli"]
siblings: ["lark-shared"]
cliHelp: "lark-cli sheets --help"
---
# sheets (v3)
# sheets
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
## 快速决策
- 已知 spreadsheet URL / token 后,再进入 `sheets +info``sheets +read``sheets +find` 等对象内部操作。
## 术语约定
## 核心概念
下列词在本 skill 各文档中可能交替出现,但**指同一对象**;解析用户口语时按此映射,不要当成不同概念:
### 文档类型与 Token
| 标准用语 | 同义 / 口语(均指同一对象) | 说明 |
| --- | --- | --- |
| 工作表sheet | 子表、tab、标签页 | spreadsheet 内的单张表;`sheet_id` 是其稳定标识 |
| 电子表格spreadsheet | 工作簿、表格 | 顶层容器;由 `--url``--spreadsheet-token` 定位 |
| reference_id | id | **表内对象**的稳定标识,即各对象主键 flag 接受的值(见下表)。⚠️ 与 `lark-sheets-float-image``--image-uri`(图片上传句柄)不是一回事,后者不属于 reference_id |
飞书开放平台中,不同类型的文档有不同的 URL 格式和 Token 处理方式。在进行文档操作(如添加评论、下载文件等)时,必须先获取正确的 `file_token`
每类对象用各自的主键 flag 定位(命名不统一,按此表对照,不要凭直觉拼):
### 文档 URL 格式与 Token 处理
| 对象 | 主键 flag | 对象 | 主键 flag |
| --- | --- | --- | --- |
| 工作表 sheet | `--sheet-id` | 条件格式规则 | `--rule-id` |
| 图表 chart | `--chart-id` | 筛选视图 | `--view-id` |
| 透视表 pivot | `--pivot-table-id` | 迷你图(按组) | `--group-id` |
| 浮动图片 | `--float-image-id` | | |
| URL 格式 | 示例 | Token 类型 | 处理方式 |
|----------|---------------------------------------------------------|-----------|----------|
| `/docx/` | `https://example.larksuite.com/docx/doxcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
| `/doc/` | `https://example.larksuite.com/doc/doccnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
| `/wiki/` | `https://example.larksuite.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | ⚠️ **不能直接使用**,需要先查询获取真实的 `obj_token` |
| `/sheets/` | `https://example.larksuite.com/sheets/shtcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
| `/drive/folder/` | `https://example.larksuite.com/drive/folder/fldcnxxxx` | `folder_token` | URL 路径中的 token 作为文件夹 token 使用 |
## 场景 → 命令速查(拿不准命令名先查这里,别按直觉拼)
### Wiki 链接特殊处理(关键!)
把高频意图映射到**真实存在**的 shortcut / flag。agent 常从 Excel / Google Sheets / 飞书 OpenAPI 误迁移命令名或 flag先对照本表避免一次必然失败的试错。完整 shortcut 见各工具参考。
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、多维表格等不同类型的文档。**不能直接假设 URL 中的 token 就是 file_token**,必须先查询实际类型和真实 token。
| 你要做的事 | ✅ 正确写法 | ❌ 不存在(会被 cobra 拒) |
| --- | --- | --- |
| 读数据(纯值 / CSV | `+csv-get`(范围用 `--range` | — |
| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `--with-styles``--with-merges``--include-merged-cells` |
| 写纯值(整块 CSV 平铺) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — |
| 写值 / 公式 / 样式 | `+cells-set`(定位用 `--range` | — |
| 查找单元格 | `+cells-search`(关键字用 `--find` | `+cells-find``+find``--query` |
| 查找并替换 | `+cells-replace` | — |
| 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `+sheet-get``+structure-get``+sheet-structure-get` |
| 看工作簿 / 子表清单 | `+workbook-info` | — |
| 导出 xlsx / 单表 csv | `+workbook-export` | — |
| 清除内容 / 格式 | `+cells-clear`(范围维度用 `--scope`,取值 content / formats / all | `--type` |
| 批量清除多区域 | `+cells-batch-clear``--scope` | `--target` |
| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `--dimension`(无此 flag |
| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | 用 SUMIF / 本地脚本拼一张假透视表 |
#### 处理流程
> ⚠️ **定位 flag**`+cells-get` / `+cells-set` / `+csv-get` 用 `--range``+csv-put` 规范用 `--start-cell`(单个左上角锚点格),也接受 `--range` 别名(区间自动取左上角),二者择一即可。
> ⚠️ **读取附加信息**一律走 `+cells-get --include …`**没有** `--with-styles` 这类 flag**看合并单元格**用 `+sheet-info` 的 `merged_cells`,不要在 `+cells-get` 里找 merge flag。
1. **使用 `wiki.spaces.get_node` 查询节点信息**
```bash
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
```
## References
2. **从返回结果中提取关键信息**
- `node.obj_type`文档类型docx/doc/sheet/bitable/slides/file/mindnote
- `node.obj_token`**真实的文档 token**(用于后续操作)
- `node.title`:文档标题
本 skill 的 reference 分两组:先读**通用方法与规范**(横切所有任务的工作流、铁律、样式、公式规则,不含具体 shortcut它们规定了"怎么做对";再按操作对象进入**工具参考**查具体 shortcut 与调用细节。编辑类任务务必先过一遍通用方法与规范,其中的铁律对所有工具参考一律生效。
3. **根据 `obj_type` 使用对应的 API**
### 通用方法与规范(先读,横切所有任务,不含具体 shortcut
| obj_type | 说明 | 使用的 API |
|----------|------|-----------|
| `docx` | 新版云文档 | `drive file.comments.*`、`docx.*` |
| `doc` | 旧版云文档 | `drive file.comments.*` |
| `sheet` | 电子表格 | `sheets.*` |
| `bitable` | 多维表格 | `bitable.*` |
| `slides` | 幻灯片 | `drive.*` |
| `file` | 文件 | `drive.*` |
| `mindnote` | 思维导图 | `drive.*` |
| Reference | 描述 |
| --- | --- |
| [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。不适用于本地 Excel 文件操作。 |
| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式高亮、标红、数据条、色阶请使用 lark-sheets-conditional-format。仅针对飞书表格不适用于本地 Excel 文件。 |
| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开时使用。仅针对飞书在线表格不适用于本地 Excel 文件执行。 |
#### 查询示例
### 按对象的工具参考(含 shortcut
| Reference | 描述 |
| --- | --- |
| [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。仅针对飞书表格。 |
| [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark-sheets-pivot-table。如需在表尾追加数据应先通过此 skill 插入行,再通过 lark-sheets-write-cells 写入。仅针对飞书表格。 |
| [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。仅针对飞书表格。 |
| [Lark Sheet Search & Replace](references/lark-sheets-search-replace.md) | 在飞书表格中搜索和替换文本,支持限定范围、大小写匹配、精确匹配、正则表达式。当用户需要"查找"、"搜索"、"定位"某个值,或"替换"、"批量修改文本"、"把 A 改成 B"时使用。不要用于理解表格结构(应读取数据)、不要用于数据分析(应读取数据后计算)、不要把用户操作动作中的关键词(如"汇总金额""统计数量")当作搜索词。仅针对飞书表格。 |
| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark-sheets-float-image若只需把一块 CSV 纯值批量铺到表格上(不带公式/样式),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark-sheets-sheet-structure 插入行列。仅针对飞书表格。 |
| [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark-sheets-write-cells。仅针对飞书表格。 |
| [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。仅针对飞书表格。 |
| [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。仅针对飞书表格。 |
| [Lark Sheet Pivot Table](references/lark-sheets-pivot-table.md) | 管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到"分组汇总"、"交叉分析"、"按XXX统计"、"按字段分组"、"再分下组"、"多维分析"、"数据透视"等场景。仅针对飞书表格。 |
| [Lark Sheet Conditional Format](references/lark-sheets-conditional-format.md) | 管理飞书表格中的条件格式规则(重复值高亮、单元格值比较、数据条、色阶、排名、自定义公式等)。当用户需要创建条件格式、修改已有规则的范围或样式、查看当前条件格式配置、删除规则时使用。也适用于用户提到"高亮"、"标红"、"颜色标记"、"数据条"、"色阶"、"条件样式"等场景。仅针对飞书表格。 |
| [Lark Sheet Filter](references/lark-sheets-filter.md) | 管理飞书表格中的筛选器filter。当用户需要筛选数据按文本/数值/颜色/日期条件过滤行)、查看已有筛选配置、修改或删除筛选器时使用。也适用于"只看"、"筛选出"、"仅保留符合条件的"等场景。仅针对飞书表格。 |
| [Lark Sheet Filter View](references/lark-sheets-filter-view.md) | 管理飞书表格中的筛选视图filter view。当用户需要"建一个 XX 视图"、"保存这个筛选状态"、"切换不同筛选"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器filter相互独立可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。仅针对飞书表格。 |
| [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。仅针对飞书表格。 |
| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。仅针对飞书表格。 |
## 公共 flag 速查
各 reference 的每个 shortcut 标题下用一行徽章标注该 shortcut 支持的公共 / 系统 flag例如
- `_公共四件套 · 系统:--dry-run_` — URL/token + sheet 定位(两组各**必给一个**,详见下方「公共 flag」`--dry-run`
- `_公共URL/token无 sheet 定位) · 系统:--yes、--dry-run_` — 只接 URL/token常见于 `+batch-update` 等不强制 sheet 定位的 shortcut
徽章里只列名字。type / 必填 / 描述都在本段统一声明:
### 公共 flag定位资源
**公共四件套** = `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,分成两组 XOR**每组都必须给且只能给一个**XOR = 二选一必填,不是"可选"
1. **spreadsheet 定位(必填)**`--url``--spreadsheet-token` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --url or --spreadsheet-token`;两个都给 → 互斥冲突。
- **`--url` 只解析 `/sheets/``/spreadsheets/` 两种链接**(从路径里抽出 token也可以直接把裸 token 传给 `--spreadsheet-token`)。其它形态的链接不会被解析成表格 token。
- ⚠️ **`/wiki/` 知识库链接不能直接当表格定位用**wiki 链接背后可能是电子表格,也可能是文档 / 多维表格等其它类型,`--url` **不会**自动把 wiki token 解析成 spreadsheet token直接传会失败。必须先把它解析成真实文档 token —— `lark-cli wiki +node-get --node-token "<wiki 链接或 token>"`,确认返回的 `obj_type``sheet` 后,取其 `obj_token` 作为 `--spreadsheet-token` 传入(解析细节见 [`../lark-wiki/SKILL.md`](../lark-wiki/SKILL.md))。
- **例外**`+workbook-create` 是新建一个还不存在的表格,**不接受任何 spreadsheet / sheet 定位 flag**(只有 `--title` / `--folder-token` / `--headers` / `--values`)。
2. **sheet 定位(公共四件套 shortcut 必填)**`--sheet-id``--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`
- ⚠️ **不确定 sheet 名时禁止直接猜 `Sheet1`**:除非用户对话明确说出 sheet 名 / id或上下文之前的工具调用 / URL 锚点 `?sheet=xxx`)已经出现过具体值,否则**第一步先调 `+workbook-info --url "..."`**(或 `--spreadsheet-token`)拿 `sheets[].sheet_id` / `sheets[].title` 列表再选。中文环境下子表常叫"数据" / "Sheet"(无数字)/ "工作表 1" / 业务名,猜 `Sheet1` 大概率撞 `sheet not found`,比先查多耗一次失败调用 + 重试。
- ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range 'Sheet1!A1:B2'`,仍**必须**额外传 `--sheet-id``--sheet-name`,否则照样报上面的错。
- ⚠️ **A1 reference 含 `!`**`--source` / `--range` / `--ranges`**shell session 起手先 `set +H`** 关 bash history expansion否则 `"Sheet1!A1"` 会被拦成 `event not found`;含特殊字符(`-` / 空格 / 非 ASCII的 sheet 名还要内部 single-quote 包,如 `--source "'Sales-2025'!A1:D100"`
- **例外**:徽章标为 `_公共URL/token无 sheet 定位…_` 的 shortcut`+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。`+pivot-create``--target-sheet-id` / `--target-sheet-name`XOR可都不传落点细节见 `lark-sheets-pivot-table`)。
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--url` | string | 二选一必填(与 `--spreadsheet-token` | spreadsheet URL |
| `--spreadsheet-token` | string | 二选一必填(与 `--url` | spreadsheet token |
| `--sheet-id` | string | 二选一必填(与 `--sheet-name`;仅公共四件套 shortcut | 工作表 reference_id |
| `--sheet-name` | string | 二选一必填(与 `--sheet-id`;仅公共四件套 shortcut | 工作表名称 |
**统一调用范式**(公共四件套 shortcut 的所有示例都遵循此形状,两组定位缺一不可):
```bash
# 查询 wiki 节点
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
lark-cli sheets <shortcut> <workbook 定位> <sheet 定位> <其它 flag>
# workbook 定位:--url "..." 或 --spreadsheet-token "..." (二选一,必给)
# sheet 定位: --sheet-id "$SID" 或 --sheet-name "<真实表名>" (二选一,必给;占位符不要原样填)
# 例lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "<真实表名>" --range "A1:F30"
# 注意:真实表名不要直接填 "Sheet1"——大多数表的子表不叫这个;先 +workbook-info 拿 sheets[].title 再代入。
```
返回结果示例:
```json
{
"node": {
"obj_type": "docx",
"obj_token": "xxxx",
"title": "标题",
"node_type": "origin",
"space_id": "12345678910"
}
}
```
### 系统 flag
### 资源关系
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--dry-run` | bool | 否 | 零副作用:仅打印请求路径与参数模板,不发起调用;多步操作会输出每个子操作的请求模板 |
| `--yes` | bool | 是(仅 `high-risk-write` | 二次确认;不带时退出码 10。详见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 高风险审批协议 |
| `--print-schema` | bool | 否 | 本地打印复合 JSON flag 的 JSON Schema 并退出,不发起任何调用、不需要其它 required flag。与 `--flag-name <name>` 搭配指定要查哪个 flag省略 `--flag-name` 时列出该 shortcut 所有可查询的 flag。**仅在 shortcut 含复合 JSON flag 时有效**——判断方法:该 shortcut 的 Flags 表里出现类型标注为「复合 JSON」的 flag`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options`)即支持;纯标量 flag 的 shortcut 不支持。 |
| `--flag-name` | string | 否 | 配合 `--print-schema` 使用,指定要打印 JSON Schema 的 flag 名(不带 `--` 前缀,如 `cells` / `properties` / `operations`)。 |
```
Wiki Space (知识空间)
└── Wiki Node (知识库节点)
├── obj_type: docx (新版文档)
│ └── obj_token (真实文档 token)
├── obj_type: doc (旧版文档)
│ └── obj_token (真实文档 token)
├── obj_type: sheet (电子表格)
│ └── obj_token (真实文档 token)
├── obj_type: bitable (多维表格)
│ └── obj_token (真实文档 token)
└── obj_type: file/slides/mindnote
└── obj_token (真实文档 token)
**Agent 使用提示**:写复合 JSON flag`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options` 等)时,如果对结构不确定,先跑 `lark-cli sheets <shortcut> --print-schema --flag-name <name>` 把完整 JSON Schema 读出来再构造 payload比靠 reference 的速查表更精确也避免因为字段拼写或缺失被服务端拒绝。reference 的 `## Schemas` 段只给一层结构,深层只能靠 `--print-schema``## Examples` 的真实示例。
Drive Folder (云空间/云盘/云存储文件夹)
└── File (文件/文档)
└── file_token (直接使用)
```
### flag 内容类型与输出约定(术语速记)
**操作流程(重要):**
- flag 表里 JSON 类入参标三类:**复合 JSON** = 深层嵌套对象(用 `--print-schema` 取完整结构);**简单 JSON** = 一维 / 二维标量数组(如 `["sheet1!A1:B2",...]` / `[["alice",95]]`,结构简单无需 print-schema**非 JSON 文本** = 原样文本(如 CSV`--print-schema` 只对**复合 JSON** flag 有效(同一 shortcut 的简单 JSON flag 如 `--colors` 不在此列)。
- **envelope**:所有 shortcut 返回统一外层结构 `{ok, identity, data, ...}`。正文里 `envelope.data` 指业务数据层(如 `+csv-get``annotated_csv`);写操作不会自动回读,如需校验请自行调用对应的 `+*-list` / `+*-get` / `+cells-get`
1. **create** — 创建筛选
- 用于首次创建筛选
- ⚠️ range 必须覆盖所有需要筛选的列(如 B1:E200
- 如果已有筛选存在,再用 create 会覆盖整个筛选
## 复合 JSON / 大入参:优先 stdin
2. **update** — 更新筛选
- 用于在已有筛选上添加/更新指定列的条件
- 只需指定 col 和 condition不需要 range
flag 帮助里标注支持 **Stdin** 的入参,当 payload 较大、含换行 / 引号等特殊字符,或已经落在某个文件里时,优先用 stdin`-`)传入,避免命令行超长与 shell 转义问题。
3. **delete** — 删除筛选
4. **get** — 获取筛选状态
**多列筛选示例:**
创建媒体名称(B列)和情感分析(E列)的双重筛选:
推荐写法payload 写到用户项目目录之外的临时文件(放系统临时目录,避免污染项目),再用 stdin 喂进去:
```bash
# 1. 删除现有筛选(如有)
lark-cli sheets spreadsheet.sheet.filters delete \
--params '{"spreadsheet_token":"<spreadsheet_token>","sheet_id":"<sheet_id>"}'
# 2. 创建第一个筛选range 覆盖所有要筛选的列
lark-cli sheets spreadsheet.sheet.filters create \
--params '{"spreadsheet_token":"<spreadsheet_token>","sheet_id":"<sheet_id>"}' \
--data '{"col":"B","condition":{"expected":["xx"],"filter_type":"multiValue"},"range":"<sheet_id>!B1:E200"}'
# 3. 添加第二个筛选条件
lark-cli sheets spreadsheet.sheet.filters update \
--params '{"spreadsheet_token":"<spreadsheet_token>","sheet_id":"<sheet_id>"}' \
--data '{"col":"E","condition":{"expected":["xx"],"filter_type":"multiValue"}}'
# TMPFILE 指向系统临时目录下的 payload 文件(脚本里用 tempfile.gettempdir() / os.tmpdir() 等取临时目录)
lark-cli sheets +cells-set --url "..." --sheet-name "Sheet1" --range "A1:B2" --cells - < "$TMPFILE"
```
**常见错误:**
- `Wrong Filter Value`:筛选已存在,需要先 delete 再 create
- `Excess Limit`update 时重复添加同一列条件
### 单元格数据类型
接受二维数组的 shortcut`+write`/`+append` 的 `--values`、`+create` 的 `--data`)中,每个单元格值支持以下类型。**公式、带文本链接、@人、@文档、下拉列表必须使用对象格式**,直接传字符串会被当作纯文本存储。
| 类型 | 写入格式 | 示例 |
|------|---------|------|
| 字符串 | `"文本"` | `"hello"` |
| 数字 | `数字` | `123`、`3.14` |
| 日期 | `数字`(自 1899-12-30 起的天数,需先设单元格日期格式) | `42101` |
| 链接(纯 URL | `"URL 字符串"` | `"https://example.com"` |
| 链接(带文本) | `{"type":"url","text":"显示文本","link":"URL"}` | `{"type":"url","text":"飞书","link":"https://www.feishu.cn"}` |
| 邮箱 | `"邮箱字符串"` | `"user@example.com"` |
| **公式** | `{"type":"formula","text":"=公式"}` | `{"type":"formula","text":"=SUM(A1:A10)"}` |
| @人 | `{"type":"mention","text":"标识","textType":"email\|openId\|unionId","notify":false}` | `{"type":"mention","text":"user@example.com","textType":"email","notify":false}`notify 可选,默认 false仅在用户明确要求通知时设为 true |
| @文档 | `{"type":"mention","textType":"fileToken","text":"token","objType":"类型"}` | `{"type":"mention","textType":"fileToken","text":"shtXXX","objType":"sheet"}` |
| 下拉列表 | `{"type":"multipleValue","values":[值1,值2]}` | `{"type":"multipleValue","values":["选项A","选项B"]}` |
**写入公式示例**
```bash
# ✅ 正确:使用对象格式
lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
--values '[[{"type":"formula","text":"=SUM(C2:C5)"}]]'
# ❌ 错误:直接传字符串,会被存为纯文本
lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
--values '[["=SUM(C2:C5)"]]'
```
> **公式语法参考**:涉及 ARRAYFORMULA、原生数组函数、MAP/LAMBDA、日期差、Excel 公式改写等飞书特有规则时,先阅读 [`references/lark-sheets-formula.md`](references/lark-sheets-formula.md)。
**限制**
- 公式支持 IMPORTRANGE 跨表引用(最多 5 层嵌套、每个工作表最多 100 个引用)
- @人仅支持同租户用户,单次最多 50 人
- 下拉列表需**先配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。配置方法见 [`references/lark-sheets-dropdown.md#set-dropdown`](references/lark-sheets-dropdown.md#set-dropdown)。值中的字符串不能包含逗号
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`)。有 Shortcut 的操作优先使用。
### Spreadsheet Management
对应参考文档:[spreadsheet-management](references/lark-sheets-spreadsheet-management.md)
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-sheets-spreadsheet-management.md#create) | Create a spreadsheet (optional header row and initial data) |
| [`+info`](references/lark-sheets-spreadsheet-management.md#info) | View spreadsheet metadata and sheet information |
| [`+export`](references/lark-sheets-spreadsheet-management.md#export) | Export a spreadsheet (async task polling + optional download) |
### Sheet Management
对应参考文档:[sheet-management](references/lark-sheets-sheet-management.md)
| Shortcut | 说明 |
|----------|------|
| [`+create-sheet`](references/lark-sheets-sheet-management.md#create-sheet) | Create a sheet in an existing spreadsheet |
| [`+copy-sheet`](references/lark-sheets-sheet-management.md#copy-sheet) | Copy a sheet within a spreadsheet |
| [`+delete-sheet`](references/lark-sheets-sheet-management.md#delete-sheet) | Delete a sheet from a spreadsheet |
| [`+update-sheet`](references/lark-sheets-sheet-management.md#update-sheet) | Update sheet title, position, visibility, freeze, or protection |
### Cell Data
对应参考文档:[cell-data](references/lark-sheets-cell-data.md)
| Shortcut | 说明 |
|----------|------|
| [`+read`](references/lark-sheets-cell-data.md#read) | Read spreadsheet cell values |
| [`+write`](references/lark-sheets-cell-data.md#write) | Write to spreadsheet cells (overwrite mode) |
| [`+append`](references/lark-sheets-cell-data.md#append) | Append rows to a spreadsheet |
| [`+find`](references/lark-sheets-cell-data.md#find) | Find cells in a spreadsheet |
| [`+replace`](references/lark-sheets-cell-data.md#replace) | Find and replace cell values |
### Cell Style And Merge
对应参考文档:[cell-style-and-merge](references/lark-sheets-cell-style-and-merge.md)
| Shortcut | 说明 |
|----------|------|
| [`+set-style`](references/lark-sheets-cell-style-and-merge.md#set-style) | Set cell style for a range |
| [`+batch-set-style`](references/lark-sheets-cell-style-and-merge.md#batch-set-style) | Batch set cell styles for multiple ranges |
| [`+merge-cells`](references/lark-sheets-cell-style-and-merge.md#merge-cells) | Merge cells in a spreadsheet |
| [`+unmerge-cells`](references/lark-sheets-cell-style-and-merge.md#unmerge-cells) | Unmerge (split) cells in a spreadsheet |
### Cell Images
对应参考文档:[cell-images](references/lark-sheets-cell-images.md)
| Shortcut | 说明 |
|----------|------|
| [`+write-image`](references/lark-sheets-cell-images.md#write-image) | Write an image into a spreadsheet cell |
### Row Column Management
对应参考文档:[row-column-management](references/lark-sheets-row-column-management.md)
| Shortcut | 说明 |
|----------|------|
| [`+add-dimension`](references/lark-sheets-row-column-management.md#add-dimension) | Add rows or columns at the end of a sheet |
| [`+insert-dimension`](references/lark-sheets-row-column-management.md#insert-dimension) | Insert rows or columns at a specified position |
| [`+update-dimension`](references/lark-sheets-row-column-management.md#update-dimension) | Update row or column properties (visibility, size) |
| [`+move-dimension`](references/lark-sheets-row-column-management.md#move-dimension) | Move rows or columns to a new position |
| [`+delete-dimension`](references/lark-sheets-row-column-management.md#delete-dimension) | Delete rows or columns |
### Filter Views
对应参考文档:[filter-views](references/lark-sheets-filter-views.md)
| Shortcut | 说明 |
|----------|------|
| [`+create-filter-view`](references/lark-sheets-filter-views.md#create-filter-view) | Create a filter view |
| [`+update-filter-view`](references/lark-sheets-filter-views.md#update-filter-view) | Update a filter view |
| [`+list-filter-views`](references/lark-sheets-filter-views.md#list-filter-views) | List all filter views in a sheet |
| [`+get-filter-view`](references/lark-sheets-filter-views.md#get-filter-view) | Get a filter view by ID |
| [`+delete-filter-view`](references/lark-sheets-filter-views.md#delete-filter-view) | Delete a filter view |
| [`+create-filter-view-condition`](references/lark-sheets-filter-views.md#create-filter-view-condition) | Create a filter condition on a filter view |
| [`+update-filter-view-condition`](references/lark-sheets-filter-views.md#update-filter-view-condition) | Update a filter condition |
| [`+list-filter-view-conditions`](references/lark-sheets-filter-views.md#list-filter-view-conditions) | List all filter conditions of a filter view |
| [`+get-filter-view-condition`](references/lark-sheets-filter-views.md#get-filter-view-condition) | Get a filter condition by column |
| [`+delete-filter-view-condition`](references/lark-sheets-filter-views.md#delete-filter-view-condition) | Delete a filter condition |
### Dropdown
对应参考文档:[dropdown](references/lark-sheets-dropdown.md)
| Shortcut | 说明 |
|----------|------|
| [`+set-dropdown`](references/lark-sheets-dropdown.md#set-dropdown) | 设置下拉列表(`multipleValue` 写入的前置步骤) |
| [`+update-dropdown`](references/lark-sheets-dropdown.md#update-dropdown) | 更新下拉列表选项 |
| [`+get-dropdown`](references/lark-sheets-dropdown.md#get-dropdown) | 查询下拉列表配置 |
| [`+delete-dropdown`](references/lark-sheets-dropdown.md#delete-dropdown) | 删除下拉列表 |
### Float Images
对应参考文档:[float-images](references/lark-sheets-float-images.md)
| Shortcut | 说明 |
|----------|------|
| [`+media-upload`](references/lark-sheets-float-images.md#media-upload) | 上传本地图片素材,返回 `file_token`(供 `+create-float-image` 使用;>20MB 自动分片) |
| [`+create-float-image`](references/lark-sheets-float-images.md#create-float-image) | 创建浮动图片 |
| [`+update-float-image`](references/lark-sheets-float-images.md#update-float-image) | 更新浮动图片属性 |
| [`+get-float-image`](references/lark-sheets-float-images.md#get-float-image) | 获取浮动图片 |
| [`+list-float-images`](references/lark-sheets-float-images.md#list-float-images) | 查询所有浮动图片 |
| [`+delete-float-image`](references/lark-sheets-float-images.md#delete-float-image) | 删除浮动图片 |
### Formula
对应参考文档:[formula](references/lark-sheets-formula.md)
> 浮动图片相关的读接口只返回元数据(含 `float_image_token`**不包含图片字节**。要读取图片内容,用 token 调 `lark-cli docs +media-preview --token "<float_image_token>" --output ./image.png`。
## API Resources
```bash
lark-cli schema sheets.<resource>.<method> # 调用 API 前必须先查看参数结构
lark-cli sheets <resource> <method> [flags] # 调用 API
```
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
### spreadsheets
- `create` — 创建电子表格
- `get` — 获取电子表格信息
- `patch` — 修改电子表格属性
### spreadsheet.sheet.filters
- `create` — 创建筛选
- `delete` — 删除筛选
- `get` — 获取筛选
- `update` — 更新筛选
### spreadsheet.sheets
- `find` — 查找单元格
### spreadsheet.sheet.float_images
- `create` — 创建浮动图片
- `patch` — 更新浮动图片
- `get` — 获取浮动图片
- `query` — 查询所有浮动图片
- `delete` — 删除浮动图片
## 权限表
| 方法 | 所需 scope |
|------|-----------|
| `spreadsheets.create` | `sheets:spreadsheet:create` |
| `spreadsheets.get` | `sheets:spreadsheet.meta:read` |
| `spreadsheets.patch` | `sheets:spreadsheet.meta:write_only` |
| `spreadsheet.sheet.filters.create` | `sheets:spreadsheet:write_only` |
| `spreadsheet.sheet.filters.delete` | `sheets:spreadsheet:write_only` |
| `spreadsheet.sheet.filters.get` | `sheets:spreadsheet:read` |
| `spreadsheet.sheet.filters.update` | `sheets:spreadsheet:write_only` |
| `spreadsheet.sheets.find` | `sheets:spreadsheet:read` |
| `spreadsheet.sheet.float_images.create` | `sheets:spreadsheet:write_only` |
| `spreadsheet.sheet.float_images.patch` | `sheets:spreadsheet:write_only` |
| `spreadsheet.sheet.float_images.get` | `sheets:spreadsheet:read` |
| `spreadsheet.sheet.float_images.query` | `sheets:spreadsheet:read` |
| `spreadsheet.sheet.float_images.delete` | `sheets:spreadsheet:write_only` |
**`@file` 接绝对路径会被拒,且被拒后不要照报错提示做。** `@file` 出于安全只接受 cwd 下的相对路径,传 cwd 之外的绝对路径会被拒。此时报错会建议"先 cd 到目标目录,或改用相对路径"——**两条都不要照做**cd 过去、或把临时文件写进用户项目目录,都会污染工作目录。正解是改用 stdin`--<flag> - < 文件`)。

View File

@@ -0,0 +1,191 @@
# Lark Sheet Batch Update
## 写入边界 + 回读校验
`+batch-update` 把多次写入打包成单次请求,但每个子操作仍受编辑类任务硬性默认规则约束:
1. **目标 range 必须落在用户授权范围内**:除用户明示要修改的区域外,子操作禁止扩张到无关单元格 / 列 / Sheet。规划 range 时先确认每个子操作的边界。
2. **批次完成后必须回读校验**:整个 `+batch-update` 执行成功后,用 `+csv-get``+cells-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与本地脚本预先计算的预期值对照。
3. **预期条数前置断言**:涉及"批量填充 N 行"或"对 M 个区域分别写入"时,先把 N、M 硬编码进代码,回读后断言实际等于预期;不一致就再发一轮 `+batch-update` 补齐,禁止交付半成品。
## 使用场景
写入。批量执行多个写入工具操作。将多个工具调用合并为一次请求,按顺序依次执行。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。注意:不支持嵌套 `+batch-update`
**不可放进 `--operations` 的写 shortcut**`shortcut` 枚举不含它们,强行写入会被校验拒):`+cells-set-image`(需本地上传图片)、`+dropdown-update` / `+dropdown-delete` / `+cells-batch-set-style` / `+cells-batch-clear`(自身已是批量入口,不可再嵌套)、`+dim-move`。这些操作需在 `+batch-update` 之外单独调用。
**⚠️ 何时必须使用 `+batch-update`(硬性要求)**
- 需要对**多个**不同区域执行 `+cells-{merge|unmerge}` 时(如按分组合并多列相同内容)
- 需要对**多个**不同区域执行 `+rows-resize / +cols-resize` 时(如统一调整多列列宽或多行行高)
- 需要先插入行列再写入数据时(`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set`
- 需要对多个区域执行不同写入操作时(多次 `+cells-set` + `+cells-clear` 等组合)
当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求——`+batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。
**`+dropdown-update` 的选项模式(`--options` / `--source-range` 二选一)+ 配色规则**`--colors` 长度可短不能长、必须配 `--highlight=true` 才生效、不传按内置 10 色色板循环补色)见 [`lark-sheets-write-cells`](./lark-sheets-write-cells.md) 的「Dropdown 选项 + 配色」节,本 skill 不重复。`+dropdown-delete` 不涉及这些 flag。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+batch-update` | high-risk-write | 批量 |
| `+cells-batch-set-style` | write | 批量 |
| `+dropdown-update` | write | 对象 |
| `+dropdown-delete` | high-risk-write | 对象 |
| `+cells-batch-clear` | high-risk-write | 批量 |
## Flags
### `+batch-update`
_公共URL/token无 sheet 定位) · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--operations` | string + File + Stdin复合 JSON | required | JSON 数组:[{"shortcut":"+xxx-yyy","input":{...}}, ...]。shortcut 用 CLI 名input 是该 shortcut 的入参集——含子表定位 sheet_id或 sheet_name但不含 spreadsheet token/url后者只在顶层 --url/--spreadsheet-token 给一次;+batch-update 顶层没有 --sheet-idinput 的键是该 shortcut 的 flag 展平成 JSON如 "range":"A11:B12"),不是再套一层嵌套。基础 flag 查 --help复合 JSON flag 查 --print-schema --flag-name <flag>;不要手填 operation 字段(由 CLI 按 shortcut 自动注入)。默认严格事务(首个失败即整批中断),传 --continue-on-error 切换为软批量(遇失败仍继续);不支持嵌套;按数组顺序串行执行 |
| `--continue-on-error` | bool | optional | 遇子操作失败时继续执行剩余操作;默认 false首个失败即整批中断 |
### `+cells-batch-set-style`
_公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["'Sheet1'!A1:B2","'Sheet2'!D1:D10"]`);前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id支持跨 sheet所有 range 应用同一组 style |
| `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff` |
| `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000` |
| `--font-size` | float64 | optional | 字体大小px10、12、14 |
| `--font-style` | string | optional | 字体样式(可选值:`normal` / `italic` |
| `--font-weight` | string | optional | 字重(可选值:`normal` / `bold` |
| `--font-line` | string | optional | 字体线条样式(可选值:`none` / `underline` / `line-through` |
| `--horizontal-alignment` | string | optional | 水平对齐(可选值:`left` / `center` / `right` |
| `--vertical-alignment` | string | optional | 垂直对齐(可选值:`top` / `middle` / `bottom` |
| `--word-wrap` | string | optional | 换行策略(可选值:`overflow` / `auto-wrap` / `word-clip` |
| `--number-format` | string | optional | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy` |
| `--border-styles` | string + File + Stdin复合 JSON | optional | 边框配置 JSON结构同 +cells-set-style |
### `+dropdown-update`
_公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组(如 `["'Sheet1'!A2:A100","'Sheet1'!C2:C100"]`),每项必须带 sheet 前缀;前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id |
| `--options` | string + File + Stdin复合 JSON | xor | 下拉选项 JSON 数组,例如 `["opt1","opt2"]`。服务端不限制选项数量,也不限制单个选项长度;含逗号的选项可以接受(写入时会自动转义)。大量选项建议改用 `--source-range`。 |
| `--colors` | string + File + Stdin简单 JSON | optional | 下拉胶囊背景色RGB hex 数组(如 `["#1FB6C1","#F006C2"]`)。长度可短不可长——超长 Validate 拦截(`--colors length (N) must not exceed dropdown source size (M)`),未指定项按内置 10 色色板循环补色。**单独传即生效**`--highlight=false` 时被忽略。 |
| `--multiple` | bool | optional | 启用多选 |
| `--highlight` | bool | optional | 下拉胶囊背景色高亮开关。**不传 = 开**(按内置 10 色色板循环上色);`--highlight=false` 关闭得到纯白下拉。配色用 `--colors` 覆盖。 |
| `--source-range` | string | xor | listFromRange 模式的下拉源 rangeA1 表示法 + sheet 前缀(如 `'Sheet1'!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表type=list传本 flag 走 range 引用type=listFromRange`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。当 `--highlight` 开启且 source 覆盖单元格数超过 2000 时,服务端会将该下拉判为 option-error这是不支持的组合CLI 会向 stderr 输出 warning。如需取消`--highlight=false`。 |
### `+dropdown-delete`
_公共URL/token无 sheet 定位) · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组(最多 100 个,如 `["'Sheet1'!E2:E6"]`),每项必须带 sheet 前缀;前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id |
### `+cells-batch-clear`
_公共URL/token无 sheet 定位) · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["'Sheet1'!A2:Z1000","'Sheet2'!A2:Z1000"]`);前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id支持跨 sheet对所有 range 执行同一 scope 的清除 |
| `--scope` | string | optional | 清除范围 enum`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all` |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+batch-update` `--operations`
_要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行;任一失败立即中断_
**数组项**(类型 object
- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-set-style / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dropdown-set / +dim-insert / +dim-delete / +dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup / +rows-resize / +cols-resize / +range-move / +range-copy / +range-fill / +range-sort / +sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy / +sheet-hide / +sheet-unhide / +sheet-set-tab-color / +chart-create / +chart-update / +chart-delete / +pivot-create / +pivot-update / +pivot-delete / +cond-format-create / +cond-format-update / +cond-format-delete / +filter-create / +filter-update / +filter-delete / +filter-view-create / +filter-view-update / +filter-view-delete / +sparkline-create / +sparkline-update / +sparkline-delete / +float-image-create / +float-image-update / +float-image-delete]
- `input` (object) — 该 shortcut 的入参集——含子表定位 sheet_id或 sheet_name但不含 spreadsheet token/url后者只在顶层 …
### `+cells-batch-set-style` `--border-styles`
_单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top_
**顶层字段**
- `top` (object?) { style?: enum, weight?: enum, color?: string }
- `bottom` (object?) { style?: enum, weight?: enum, color?: string }
- `left` (object?) { style?: enum, weight?: enum, color?: string }
- `right` (object?) { style?: enum, weight?: enum, color?: string }
### `+dropdown-update` `--options`
_列表选项_
**数组项**(类型 string
- 标量string
## Examples
公共四件套:`--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(前两者 XOR`+batch-update` 本身不强制 sheet-id子操作各自携带
### `+batch-update`
示例:
```bash
lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --yes \
--operations @ops.json
# ops.json array<{shortcut, input}>shortcut 用 CLI 名):
# [
# {"shortcut": "+dim-insert", "input": {"sheet_id":"...","dimension":"row","start":10,"end":12}},
# {"shortcut": "+cells-set", "input": {"sheet_id":"...","range":"A11:B12","cells":[[{"value":"a"},{"value":"b"}],[{"value":"c"},{"value":"d"}]]}}
# ]
```
> ⚠️ **子操作定位规则**
> - spreadsheet 定位(`--url` / `--spreadsheet-token`**只在顶层给一次**`+batch-update` 顶层**没有** `--sheet-id` / `--sheet-name`,在顶层传不生效。
> - **每个子操作的子表定位 `sheet_id`(或 `sheet_name`)写进它自己的 `input`**(见上方 ops.json 每个 item
> - `input` 的键是该 shortcut 的 flag **展平**成 JSON`"range":"A11:B12"`、`"dimension":"row"`),不要把整组 `--operations` 再套一层嵌套 JSON。
> **常见组合:插列 + 写表头 + 整列回填**——一次原子提交,不要拆成 N 次独立调用。批量回填同一列 **只需一次** `+cells-set`range 写整列范围、cells 写 N×1 矩阵),不需要逐行循环。
>
> ```jsonc
> // 在 C 列前插入新列 → 写表头 C1 → 回填 C2:C100 共 99 行
> [
> {"shortcut": "+dim-insert",
> "input": {"sheet_id": "...", "dimension": "column", "start": 3, "end": 4}},
> {"shortcut": "+cells-set",
> "input": {"sheet_id": "...", "range": "C1:C100",
> "cells": [[{"value":"score"}], [{"value":95}], [{"value":87}], /* ... 97 more rows ... */ ]}}
> ]
> ```
### `+cells-batch-set-style`
多 range 应用同一组 style服务端走 `+batch-update` 原子事务):
```bash
# 表头行 + 汇总行同时刷成蓝底白字
lark-cli sheets +cells-batch-set-style --url "..." \
--ranges '["sheet1!A1:F1","sheet1!A30:F30"]' \
--background-color "#1E5BC6" --font-color "#FFFFFF" --font-weight bold
```
### `+cells-batch-clear`
多 range 一次性清除(服务端走 `+batch-update` 原子事务);`--scope``+cells-clear``content` / `formats` / `all`,默认 `content``high-risk-write` 强制 `--yes`
```bash
# dry-run 先看清除范围
lark-cli sheets +cells-batch-clear --url "..." \
--ranges '["sheet1!A2:Z1000","sheet2!A2:Z1000"]' --scope all --dry-run
# 执行
lark-cli sheets +cells-batch-clear --url "..." \
--ranges '["sheet1!A2:Z1000","sheet2!A2:Z1000"]' --scope all --yes
```
### Validate / DryRun / Execute 约束
- `Validate``+batch-update``--operations` 必须合法 JSON且为非空数组逐个子操作 `shortcut` / `input` 字段必填校验;**禁止嵌套 `+batch-update`**。`+cells-batch-set-style``--ranges` 必须 JSON 数组、每项带 sheet 前缀;样式 flag 至少一个非空(或带 `--border-styles`)。`+cells-batch-clear``--ranges` 同样必须 JSON 数组、每项带 sheet 前缀,`high-risk-write` 强制 `--yes``--dry-run``--scope` 默认 `content`)。
- `DryRun`:按顺序输出每个子操作的目标 API + 请求 body 模板;首个失败则整批 fail-fast不实际执行任何后续
- `Execute`:按声明顺序串行执行;任一子操作失败立即中断并回滚到该子操作前状态(具体回滚能力取决于子操作类型,沿用 `+batch-update` 的语义)。

View File

@@ -1,197 +0,0 @@
# Sheets Cell Data
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
这份 reference 汇总单元格数据操作:
- `+read`
- `+write`
- `+append`
- `+find`
- `+replace`
<a id="read"></a>
## `+read`
对应命令:`lark-cli sheets +read`
内置能力:
- 支持 `--url` / `--spreadsheet-token` 二选一URL 支持 wiki
- 若已传 `--sheet-id``--range` 可写 `A1:D10``C2`
- 默认最多返回 200 行
```bash
lark-cli sheets +read --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--range "<sheetId>!A1:H20"
lark-cli sheets +read --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --range "C2"
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--range` | 否 | `<sheetId>!A1:D10``A1:D10` / `C2``<sheetId>` |
| `--sheet-id` | 否 | 工作表 ID |
| `--value-render-option` | 否 | `ToString` / `FormattedValue` / `Formula` / `UnformattedValue` |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:
- `range`
- `values`
- `truncated`
- `total_rows`
<a id="write"></a>
## `+write`
对应命令:`lark-cli sheets +write`
用于覆盖写入一个矩形区域。
```bash
lark-cli sheets +write --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A1:B2" \
--values '[["name","age"],["alice",18]]'
lark-cli sheets +write --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>" --range "C2" \
--values '[["hello"]]'
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--range` | 否 | 写入范围;可用相对范围或 `<sheetId>` |
| `--sheet-id` | 否 | 工作表 ID |
| `--values` | 是 | 二维数组 JSON |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:
- `updated_range`
- `updated_rows`
- `updated_columns`
- `updated_cells`
- `revision`
<a id="append"></a>
## `+append`
对应命令:`lark-cli sheets +append`
用于向工作表末尾追加行。
```bash
lark-cli sheets +append --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A1" \
--values '[["华东一仓","2026-03",125000,98000,168000,"41.7%"]]'
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--range` | 否 | 追加范围:支持 `<sheetId>`、完整范围、相对范围 |
| `--sheet-id` | 否 | 工作表 ID |
| `--values` | 是 | 二维数组 JSON |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:
- `table_range`
- `updated_range`
- `updated_rows`
- `updated_columns`
- `updated_cells`
- `revision`
<a id="find"></a>
## `+find`
对应命令:`lark-cli sheets +find`
只在一个已知 spreadsheet 内查找单元格内容,不是云空间(云盘/云存储)搜索。
```bash
lark-cli sheets +find --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>" --find "张三" --range "A1:H200"
lark-cli sheets +find --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --find "仓库管理营收报表" --ignore-case
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--find` | 是 | 查找内容 |
| `--range` | 否 | 范围;不填则搜索整个工作表 |
| `--ignore-case` | 否 | 不区分大小写 |
| `--match-entire-cell` | 否 | 完全匹配单元格 |
| `--search-by-regex` | 否 | 使用正则 |
| `--include-formulas` | 否 | 搜索公式 |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:
- `matched_cells`
- `matched_formula_cells`
- `rows_count`
<a id="replace"></a>
## `+replace`
对应命令:`lark-cli sheets +replace`
在指定范围内查找并替换单元格内容。
```bash
lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --find "hello" --replacement "world"
lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --find "\\d{4}-\\d{2}-\\d{2}" \
--replacement "DATE" --search-by-regex
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--find` | 是 | 搜索文本 |
| `--replacement` | 是 | 替换文本 |
| `--range` | 否 | 搜索范围,不传则搜索整个工作表 |
| `--match-case` | 否 | 区分大小写 |
| `--match-entire-cell` | 否 | 匹配整个单元格 |
| `--search-by-regex` | 否 | 使用正则 |
| `--include-formulas` | 否 | 在公式中搜索 |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:
- `replace_result.matched_cells`
- `replace_result.matched_formula_cells`
- `replace_result.rows_count`
## 参考
- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 先获取 `sheet_id`
- [dropdown](lark-sheets-dropdown.md#set-dropdown) — 写入 `multipleValue` 前先设置下拉列表
- [formula](lark-sheets-formula.md) — 公式写入规则

View File

@@ -1,59 +0,0 @@
# Sheets Cell Images
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
这份 reference 汇总单元格图片写入能力:
- `+write-image`
<a id="write-image"></a>
## `+write-image`
对应命令:`lark-cli sheets +write-image`
特性:
- 将本地图片文件写入到指定单元格
- 支持格式PNG、JPEG、JPG、GIF、BMP、JFIF、EXIF、TIFF、BPG、HEIC
- `--range` 必须表示单个单元格,如 `A1``<sheetId>!B2:B2`
- `--name` 默认取 `--image` 的文件名
```bash
# 写入图片到指定单元格
lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!B2:B2" \
--image "./logo.png"
# 使用 URL + sheet-id指定单个单元格
lark-cli sheets +write-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>" --range "C3" \
--image "./chart.jpg"
# 自定义图片名称
lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A1:A1" \
--image "./output.png" --name "revenue_chart.png"
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--range` | 是 | 目标单元格:`<sheetId>!A1:A1` 或相对单元格 |
| `--sheet-id` | 否 | 工作表 ID |
| `--image` | 是 | 本地图片文件的相对路径 |
| `--name` | 否 | 图片文件名(默认取 `--image` 的文件名) |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:
- `spreadsheetToken`
- `updateRange`
- `revision`
## 参考
- [cell-data](lark-sheets-cell-data.md#write) — 写入普通单元格数据
- [float-images](lark-sheets-float-images.md) — 管理浮动图片

View File

@@ -1,141 +0,0 @@
# Sheets Cell Style and Merge
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
这份 reference 汇总单元格样式和合并相关操作:
- `+set-style`
- `+batch-set-style`
- `+merge-cells`
- `+unmerge-cells`
<a id="set-style"></a>
## `+set-style`
对应命令:`lark-cli sheets +set-style`
对指定范围设置字体、颜色、对齐、边框等样式。
```bash
lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A1:C3" \
--style '{"font":{"bold":true},"backColor":"#ff0000"}'
lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A1:Z100" --style '{"clean":true}'
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--range` | 是 | 单元格范围 |
| `--sheet-id` | 否 | 工作表 ID用于相对范围 |
| `--style` | 是 | 样式 JSON 对象 |
| `--dry-run` | 否 | 仅打印请求,不执行 |
常用 `style` 字段:
- `font.bold`
- `font.italic`
- `font.font_size`
- `textDecoration`
- `formatter`
- `hAlign`
- `vAlign`
- `foreColor`
- `backColor`
- `borderType`
- `borderColor`
- `clean`
输出:`updates`updatedRange / updatedRows / updatedColumns / updatedCells / revision
<a id="batch-set-style"></a>
## `+batch-set-style`
对应命令:`lark-cli sheets +batch-set-style`
对多个范围批量设置不同样式。
```bash
lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \
--data '[{"ranges":["<sheetId>!A1:C3"],"style":{"font":{"bold":true},"backColor":"#21d11f"}},{"ranges":["<sheetId>!D1:F3"],"style":{"foreColor":"#ff0000"}}]'
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--data` | 是 | JSON 数组,每项包含 `ranges``style` |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:
- `totalUpdatedRows`
- `totalUpdatedColumns`
- `totalUpdatedCells`
- `revision`
- `responses[]`
<a id="merge-cells"></a>
## `+merge-cells`
对应命令:`lark-cli sheets +merge-cells`
支持三种模式:
- `MERGE_ALL`
- `MERGE_ROWS`
- `MERGE_COLUMNS`
```bash
lark-cli sheets +merge-cells --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A1:B2" --merge-type MERGE_ALL
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--range` | 是 | 单元格范围 |
| `--sheet-id` | 否 | 工作表 ID用于相对范围 |
| `--merge-type` | 是 | `MERGE_ALL` / `MERGE_ROWS` / `MERGE_COLUMNS` |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:`spreadsheetToken`
<a id="unmerge-cells"></a>
## `+unmerge-cells`
对应命令:`lark-cli sheets +unmerge-cells`
用于拆分合并单元格。
```bash
lark-cli sheets +unmerge-cells --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A1:B2"
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--range` | 是 | 单元格范围 |
| `--sheet-id` | 否 | 工作表 ID用于相对范围 |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:`spreadsheetToken`
## 参考
- [cell-data](lark-sheets-cell-data.md) — 数据读写
- [cell-images](lark-sheets-cell-images.md) — 写入单元格图片

View File

@@ -0,0 +1,319 @@
# Lark Sheet Chart
## 真对象硬约束
当用户要求"画个图 / 数据可视化 / 趋势图 / 对比图 / 占比图"时,**必须**通过 `+chart-{create|update|delete}` 创建真实的图表对象。**禁止**用本地脚本调 matplotlib / seaborn 生成图片再插入到表格代替——静态图片无法随源数据更新,且失去交互能力。判断标准:交付后 `+chart-list` 必须能返回该对象。
## 使用场景
读写图表对象。本 reference 覆盖 4 个 shortcut
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看已有图表 | `+chart-list` | 获取图表的类型、数据源和样式配置 |
| 创建/更新/删除图表 | `+chart-{create|update|delete}` | 对图表对象执行写入操作 |
典型工作流:先读取现有图表了解配置 → 执行创建/更新/删除 → 再次读取验证结果。
## 需求→图表类型映射(创建前必查)
| 用户说 | 图表类型 | 备注 |
|--------|---------|------|
| "占比"、"比例"、"各XX占多少" | 饼图pie | 单维度占比首选 |
| "对比"、"各XX的YY" | 柱形图column纵向 | 多类别数值对比;横向条形用 `bar` |
| "趋势"、"变化"、"走势" | 折线图line | 时间序列首选 |
| "堆积"、"组成构成" | 堆积柱形图column + stack | 多系列累加 |
| "分布"、"相关性" | 散点图scatter | 两变量关系 |
**多图表需求**:当用户同时提到多种分析(如"统计占比 + 对比数量"),必须创建多个图表,每个对应一种类型,不要只做一个。
**`--properties` 结构锚点(构造前必读)**`--properties` 顶层只有 `position` / `offset` / `size` / `snapshot` 四个字段,**没有**顶层 `data`,也没有再嵌一层 `properties`。图表数据配置全部挂在 `snapshot.data` 下——下文及示例里出现的 `refs` / `headerMode` / `dim1` / `dim2` / `nameRef` 一律指 `snapshot.data.refs` / `snapshot.data.headerMode` / `snapshot.data.dim1` / `snapshot.data.dim2`(及其下的 `serie.nameRef` / `series[].nameRef`);样式 / 堆叠 / 数据标签等在 `snapshot.plotArea` 下。完整结构以 `lark-cli sheets +chart-create --print-schema --flag-name properties` 为准。
**常见配置错误(必须注意)**
- **图表类型选择错误**:用户说"堆积柱形图/百分比堆积"时,应在 `properties.snapshot.plotArea.plot.extra.stack` 中配置堆叠;百分比堆叠需在该 stack 下设置 `percentage: true`。用户说"占比/比例"时,优先考虑饼图或百分比堆积图。注意区分 `column`(柱形图,纵向)与 `bar`(条形图,横向)是两个不同的 type 取值,"对比/各 XX" 类纵向柱默认用 `column`
- **数据标签缺失**:用户需要看到具体数值时,需配置 `properties.snapshot.plotArea.plot.labels`(数据标签)相关字段
- **数据源范围与系列名来源要对齐**
- **默认情况inline 模式)**`refs` 范围**应包含表头行**(首行/首列即系列名),且范围要精确覆盖目标数据,不要多选或少选。
- **合并标题行要跳过**:如果表格在表头上方存在合并的标题行(如"员工统计表"横跨多列的大标题),`refs` 必须跳过标题行、从真正的列标题行开始。例如表头在第 3 行、数据在第 4-20 行,则 `refs` 应为 `A3:G20` 而非 `A1:G20`。包含合并标题行会导致列名识别错误、表头被当作数据参与聚合计算。
- **数据与表头分离时必须用 detached 模式**:当 `refs` 只覆盖完整数据的一个子集(按筛选/分组只画其中一段),而真正的语义表头在该子集之外时,**必须**设置 `snapshot.data.headerMode='detached'`refs 仅传纯数据范围,维度名/系列名通过 `snapshot.data.dim1.serie.nameRef` / `snapshot.data.dim2.series[].nameRef` 指向真正的表头单元格。详见下文"硬性规则:数据与表头分离场景必须使用 detached 模式"。
- **axes[].label 不接受 `format` / `number_format` 字段**:想给坐标轴数值加千分位、百分号等格式化时,不要在 `axes[i].label` 里传 `format``number_format`schema 未定义,会报 `unexpected property "format" is not defined in schema`)。数值格式化统一在源数据单元格的 `cell_styles.number_format` 里设置(写 `+cells-set` 时),图表会沿用单元格格式。
- **创建后必须验证**:图表创建后必须调用 `+chart-list` 验证配置是否正确
> **⚠️ 硬性规则:当用户通过列标题名称(而非列索引)指定横轴/纵轴系列时,必须先读取表格首行(表头)来确定列名与列索引的对应关系,再设置 `dim1`/`dim2` 的 `index`。**
> 例如用户说"横轴为车型系列纵轴为Q1-Q4的销量",你不能猜测列索引,必须先通过读取表格数据源范围的首行内容(使用 `lark-sheets-read-data` 的 `+cells-get` 或其他读取单元格的工具),确认"车型系列"是第几列、"Q1"~"Q4"分别是第几列,然后再将正确的列索引填入 `dim1.serie.index` 和 `dim2.series[].index`。
> **⚠️ 硬性规则:数据与表头分离场景必须使用 detached 模式。** 当 `refs` 仅覆盖数据的一个子集,而真正的语义表头行/列位于该子集之外时,**必须** `snapshot.data.headerMode='detached'` 并配上 `nameRef`。不能用 inline 模式 + 把 refs 多带 1 行兜底表头来替代——那种写法已废弃。否则图表会把错误的首行/首列当系列名,或图例显示成"系列1/系列2"等默认名,或者 refs 里混入相邻分组的数据。
>
> **触发该规则的典型信号**(满足任意一条都必须走 detached
> - 用户要求"针对 X 类的数据画图"、"只看某个分组"、"只画筛选后的部分",而 X 类对应的行段在数据中间或末尾,与表头不连续;
> - 用户要求"按 X 分别画图"、"按某个维度(部门/品类/地区/时间段等)拆图"——**多张图共享同一组表头**
> - `refs` 起始行 > 表头行(如表头在第 1 行,但 `refs` 从第 11 行开始);
> - `refs` 起始列 > 表头列(如表头在 A 列,但 `refs` 从 C 列开始)。
>
> **正确做法**
> 1. 在 `data` 下显式设置 `"headerMode": "detached"`
> 2. `refs` **只覆盖该子集的纯数据**,不要向上/向左多带 1 行/列,也不要把全局表头整段并进来(否则会把其它分组的数据混进图);
> 3. **`nameRef` 必填**:给 `dim1.serie.nameRef` 写真正表头中"类别名"那一格的 A1 引用(如 `'Sheet2'!A1`sheet 名按 A1 标准单引号包裹),给每个 `dim2.series[i].nameRef` 写对应数值列的 A1 引用(如 `'Sheet2'!C1`、`'Sheet2'!D1`)。任一缺失会被校验拦下并报 `headerMode=detached requires ... nameRef`
> 4. `refs[i].value` 必须是单元格或普通矩形范围CELL / NORMAL不接受整行/整列/开区间;`direction='column'` 时起始行必须 > 0`direction='row'` 时起始列必须 > 0
> 5. `index` 仍按 `refs` 内的列/行号填,从 1 开始。
>
> **两种场景对照(互斥,二选一)**
>
> | 场景 | 何时命中 | 写法 |
> |---|---|---|
> | A. 表头与数据连在一起 | 单张图、refs 首行/首列就是表头(典型整段画图) | **省略 headerMode**(默认 inlinerefs 含表头,**不写 nameRef** |
> | B. 表头与数据分离 | 上面 4 条信号任一命中(数据子集、按维度拆图等) | **`headerMode='detached'`**refs 仅纯数据,**`nameRef` 必填** |
>
> **反向约束**:场景 A 下不要写 `nameRef`——首行命名已经生效,多写反而冗余。`nameRef` 仅在场景 B 下使用(且必填)。
## ⚠️ chart 数据源引用 pivot 时必须排除总计行(高频致命错误)
当 chart 要基于刚创建的 pivot 产物画图时,**禁止凭猜写 `refs`**。pivot 默认启用 `show_row_grand_total` / `show_col_grand_total`,产物最后一行/一列通常是"总计"。如果 `refs` 把总计行一并框进去:
- **柱形图**末尾会多一根天文数字柱子(=所有数据求和),把其他柱子压扁到看不见
- **饼图**会多一个"总计"扇区占 33%+,真实类别的比例完全失真
**正确流程**
1. `+pivot-create create` 返回 `sheet_id` + `pivot_table_id`
2.`+csv-get(sheet_id, 'A1:E30')``+pivot-list` 读 pivot 产物的**实际数据范围**
3. 识别并排除"总计"/"小计"行(通常最后一行;嵌套 pivot 还要排除中间层小计)
4. `+chart-create create``snapshot.data.refs` 精确到数据行(如 pivot 占 A1:D9、总计在 row9 → chart 用 `A1:D8`
## 图表位置选择(创建前必做)
凭感觉挑列号/行号会被 API 拒(`position is out of sheet range`)。按以下四步走:
1. **查尺寸**`+workbook-info` 拿该 sheet 的 `row_count` / `column_count`(下文记为 rowCount / columnCount`+sheet-info` 只返回布局,不含行列总数)。
2. **估跨度**:默认单元格 **105 px 宽 × 27 px 高**`needCols = ceil(width/105)``needRows = ceil(height/27)`
3. **校验**`position.row + needRows ≤ rowCount``col_idx + needCols ≤ columnCount`col 按 A=0、B=1、…、Z=25、AA=26… 换算)。
4. **不够就先扩表**,二选一,禁止硬塞越界位置:
- **优先**放数据下方空区:`position = {row: data_end_row + 2, col: "A"}`
- 否则先调 `+dim-insert``lark-sheets-sheet-structure`)扩行/列,再 create。
**示例**21 列 sheet 放 600×400 图 → `needCols=6, needRows=15`
-`{row: 0, col: "W"}` — col=22 越界
-`{row: 42, col: "A"}` — 放数据下方
- ✅ 先 `+dim-insert --dimension column --start 21 --end 27`(在 U 列后插 6 列U=index 20after 即从 21 起),再放图到 `{row: 0, col: "V"}`
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+chart-list` | read | 对象 |
| `+chart-create` | write | 对象 |
| `+chart-update` | write | 对象 |
| `+chart-delete` | high-risk-write | 对象 |
## Flags
### `+chart-list`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-id` | string | optional | 指定单个图表 reference_id 过滤 |
### `+chart-create`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--properties` | string + File + Stdin复合 JSON | required | 图表完整配置 JSON。顶层字段为 `position` / `offset` / `size` / `snapshot`(无顶层 `data`,也无再嵌一层 `properties`);图表数据配置在 `snapshot.data` 下(含 `refs` / `headerMode` / `dim1` / `dim2`)。结构嵌套深,完整结构跑 `--print-schema --flag-name properties` |
### `+chart-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-id` | string | required | 目标图表 reference_id |
| `--properties` | string + File + Stdin复合 JSON | required | 完整或足够完整的图表配置 JSON`+chart-list` 回读再 patch |
### `+chart-delete`
_公共四件套 · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-id` | string | required | 目标图表 reference_id |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+chart-create` `--properties` / `+chart-update` `--properties`
_创建/更新的图表属性_
**顶层字段**
- `position` (object) — 必填 { row: number, col: string }
- `offset` (object?) — 可选 { row_offset?: number, col_offset?: number }
- `size` (object) — 必填 { width: number, height: number }
- `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea: object, …共 6 项 }
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`XOR 规则同 `+csv-get`)。
### `+chart-list`
输出契约:返回按工作表分组的图表列表,每个图表含 `chart_id` / `position` / `details.snapshot` 等。
### `+chart-create`
> **`snapshot.data` 必填 `dim1.serie.index` 或 `dim2.series[].index` 之一**1-based对应 `refs.value` 范围内的列序。schema 允许传空 `{}` 但 server 运行时强制:缺则被拒为 `snapshot.data.dim1.serie.index and dim2.series[].index are both missing; at least one must be set`,即便侥幸通过也只会渲染空图。
最小可用列图inline 模式refs 含表头行):
```bash
lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \
--sheet-name "Sheet1" --properties '{
"position":{"row":42,"col":"A"},
"size":{"width":600,"height":400},
"snapshot":{
"data":{
"refs":[{"value":"'Sheet1'!A1:B10"}],
"dim1":{"serie":{"index":1}},
"dim2":{"series":[{"index":2}]}
},
"plotArea":{"plot":{"type":"column"}}
}
}'
# 走文件(推荐配置较多时)
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties @chart-config.json
```
**饼图专属示例**`sectors` 必须嵌在 `plotArea.plot.series[i].sectors.sector[]`,且 `sector[].index` 1-based
饼图比 column / bar 更复杂:`sectors` 是 object里面再包一个**单数** `sector` 数组——CLI 不替你 normalize写错路径会被 server schema 直接拒。
```bash
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{
"position":{"row":24,"col":"F"},
"size":{"width":600,"height":450},
"snapshot":{
"title":{"text":"各部门员工人数占比"},
"plotArea":{"plot":{
"type":"pie",
"series":[{
"index":1,
"sectors":{"sector":[{"index":1,"offsetRadius":0.05}]}
}]
}},
"data":{
"refs":[{"value":"'Sheet1'!A1:B11"}],
"dim1":{"serie":{"index":1,"aggregate":true}},
"dim2":{"series":[{"index":2,"aggregateType":"sum"}]}
}
}
}'
```
**数据与表头分离(必须用 `detached` + `nameRef`**
场景:周度销量明细表,真实表头在第 1 行A1=周次、C1=订单量、D1=退款量),数据按 B 列"店铺"分段;用户只要"3 号店"那一段(第 1117 行)。
```bash
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties '{
"position":{"row":7,"col":"F"},
"size":{"width":600,"height":360},
"snapshot":{
"title":{"text":"3 号店周度订单/退款"},
"plotArea":{"plot":{"type":"column"}},
"data":{
"headerMode":"detached",
"direction":"column",
"refs":[{"value":"'Sheet2'!A11:D17"}],
"dim1":{"serie":{"index":1,"nameRef":"'Sheet2'!A1"}},
"dim2":{"series":[
{"index":3,"nameRef":"'Sheet2'!C1"},
{"index":4,"nameRef":"'Sheet2'!D1"}
]}
}
}
}'
```
约束:
- `refs` 只覆盖纯数据 `A11:D17`**不要**把表头行 A1 并进来
- `nameRef` 在 detached 模式下**必填**,缺了被校验报 `headerMode=detached requires ... nameRef`
- `index` 按 refs 内的列序算A=1、B=2、C=3、D=4**不是**全表列号
- `nameRef` 必须配对应的 `index`;单写 `nameRef` 不传 `index` 直接报参数错
**多张图共享同一组表头(按维度拆图,必须用 detached**
场景:销售明细表头在 A1:E1月份/区域/销售额/订单数/客单价),数据按区域分 3 段(华北 A2:E9、华东 A10:E17、华南 A18:E25要分别画 3 张图。
❌ 常见错误:
```jsonc
// 错误 1refs 含全局表头但跨段 —— 多个区域被混进同一张图
{"data":{"refs":[{"value":"'Sheet'!A1:E17"}], ... }} // 华东图混进华北 8 行
// 错误 2inline + refs 只取数据段、不写 detached/nameRef —— 图例显示成具体数据值
{"data":{"refs":[{"value":"'Sheet'!A10:E17"}],"dim1":{"serie":{"index":1}}, ... }}
```
✅ 正确模式3 张图各自 detached、refs 干净不重叠:
```jsonc
// 图 1华北
{"data":{
"headerMode":"detached","direction":"column",
"refs":[{"value":"'Sheet'!A2:E9"}],
"dim1":{"serie":{"index":1,"nameRef":"'Sheet'!A1"}},
"dim2":{"series":[
{"index":3,"nameRef":"'Sheet'!C1"},
{"index":4,"nameRef":"'Sheet'!D1"}
]}
}}
// 图 2华东 —— refs 改 'Sheet'!A10:E17其余同上
// 图 3华南 —— refs 改 'Sheet'!A18:E25其余同上
```
> `--properties` JSON 关键字段:
> - `position.row` / `position.col` 必须留足空间,越界会被 API 拒(按本文件"图表位置选择"四步走)
> - `snapshot.data.headerMode`:默认 inline当 refs 仅覆盖数据子集而语义表头在子集之外,必须 `detached` + `nameRef`
> - chart 引用 pivot 输出时,`snapshot.data.refs` 必须排除总计 / 小计行
### `+chart-update`
**Update 三步法**(缺一步会丢字段):
1. `+chart-list --chart-id <id>` 拿到完整 snapshot
2. 在拿到的 snapshot 上**局部**修改要改的字段,其余保持不变
3. 把**完整 snapshot** 整个回写到 `--properties.snapshot`
```bash
lark-cli sheets +chart-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--properties '{
"position":{"row":0,"col":"A"},
"size":{"width":480,"height":320},
"snapshot": <完整快照(由 +chart-list 取回后局部修改)>
}'
```
> 关键:**不能只提交局部 snapshot**,否则未传字段会被还原为默认值。`+chart-update` 的语义是 PUT整体覆盖不是 PATCH。
### `+chart-delete`
示例:
```bash
# dry-run 先看会删什么sheet 定位必填)
lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" --sheet-id "$SID" \
--chart-id "chrXXX" --dry-run
# 真正执行
lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" --sheet-id "$SID" \
--chart-id "chrXXX" --yes
```
### Validate / DryRun / Execute 约束
- `Validate`XOR 公共四件套;`+chart-create` / `+chart-update``--properties` 必须能解析为合法 JSON`+chart-delete`high-risk-write校验 `--yes``--dry-run` 至少一个。
- `DryRun``+chart-create` / `+chart-update` 输出"将要 POST 的 body 模板"`+chart-delete` 输出"将要删除的 chart_id 及隶属 sheet",零网络副作用。
- `Execute`:写操作执行后不自动回读;如需确认,自行调用 `+chart-list` 比对结果。
> `+chart-create` / `+chart-update` 是 write 级别,按需可用 `--dry-run` 预览,不要求 `--yes`。只有 `+chart-delete`high-risk-write必须 `--yes`。

View File

@@ -0,0 +1,179 @@
# Lark Sheet Conditional Format
## 真对象硬约束 + 触发词清单
用户出现以下口语指令时,**强制**走 `+cond-format-{create|update|delete}`**禁止**用 `+cells-set` 写静态背景色 / 字体色代替:
- **颜色动作**"标红 / 标黄 / 标绿 / 上色 / 染色 / 涂色 / 表红色 / 表黄色"
- **视觉强调**"高亮 / 突出 / 标记 / 标注 / 区分"
- **条件触发**"重复的标出来 / 异常的圈出来 / 过期的染红 / 大于 X 的标黄 / 不达标的标红"
- **联动语义**"颜色随数据变 / 联动 / 自动更新 / 改了数据颜色也跟着变"
- **数值可视化**"数据条 / 色阶 / 渐变色 / 进度条样式"
飞书表格的"颜色标记"语义 = 条件格式规则 ≠ 静态背景色。如果用 `+cells-set` 写静态,源数据变化时颜色不会跟着变(典型反例:用户要求"过期单元格标红"时,模型用静态填充——日期变化后单元格颜色不再准确反映过期状态)。
**判断标准**:交付后 `+cond-format-list` 必须能返回该规则;否则视为违规。
**大数据量首选**:当数据量 > 1000 行时,条件格式是首选——它由飞书自身渲染,比"本地脚本逐行计算 + `+cells-set` 写静态背景色"更高效、更稳(颜色还能随源数据自动联动)。
## 使用场景
读写条件格式对象。本 reference 覆盖 4 个 shortcut
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看已有条件格式 | `+cond-format-list` | 获取规则类型、范围和样式配置 |
| 创建/更新/删除条件格式 | `+cond-format-{create|update|delete}` | 对条件格式规则执行写入操作 |
典型工作流:先读取现有条件格式了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**
**常见配置错误(必须注意)**
- **创建后必须验证**:条件格式创建后必须调用 `+cond-format-list` 验证规则是否生效。如果验证发现规则未生效或配置不正确,应立即修复并重试
- **范围要精确**:条件格式的应用范围必须精确覆盖用户指定的列/行,不要遗漏
- **`style.back_color` vs `style.fore_color` 的中文语义**:用户中文语境下的"**标红/高亮/染色/标记**"指**单元格背景色**,用 `back_color`"**文字红/字体红/把字变红**"才用 `fore_color`。默认无说明时选 `back_color`。把过期数据涂红、重复值高亮等都应该是 `back_color: "#FFE6E6"`(或类似浅红)配合可选的 `fore_color` 加深字体
- **日期/空值比较必须防空**:用户说"过期的标红"时,除了 `TODAY()`,公式必须排除空单元格,否则空白格也会被误判为"早于今天"而全表标红。正确公式:`=AND(E1<>"", E1<=TODAY())`;错误公式:`=E1<=TODAY()`(空值会被当作 0 判为过期)
- **公式条件注意引用方式**:自定义公式条件中的单元格引用需要根据实际场景选择相对/绝对引用(如 `=E1<=TODAY()` 而非 `=$E$1<=TODAY()`,后者只比较一个格)
⚠️ **用户明确要求"辅助列+条件格式"两步走时,禁止用 `expression` 绕过(高频致命错误)**:当用户说以下任意一种表达时,必须按两步走(先建辅助列 → 再基于辅助列做条件格式),**禁止**直接用一个 `rule_type: "expression"` 公式一步完成:
- "**增加辅助列**,再/然后标记……"
- "**先计算/判断** XX **是否** YY**再**标记……"
- "**新建一列**放结果,再用结果染色"
- 明确要求用 "辅助列"、"辅助字段"、"判断列"、"标记列"
**正确做法(两步走)**
```
Step 1: `+cells-set` 在新列写判断公式(形成"是/否"或布尔辅助列)
range="H2", cells=[[{formula: "=IF(A2>B2, \"是\", \"否\")"}]], --copy-to-range="H2:H100"
Step 2: 基于辅助列值做条件格式(用 cellIs 或引用辅助列的 expression
`+cond-format-{create|update|delete}` create
rule_type: "expression"
ranges: ["A2:H100"] // 整行高亮
attrs: [{formula: ["=$H2=\"是\""]}] // 引用辅助列
style: {back_color: "#FFECEC"}
```
**错误做法(一步走绕过辅助列)**
```
`+cond-format-{create|update|delete}` create
rule_type: "expression"
ranges: ["2:145"]
attrs: [{formula: ["=$O2>$H2"]}] ← 虽然逻辑等价,但产物里缺辅助列 → 不满足用户明确要求的"辅助列"诉求
```
为什么禁止一步走:用户明确要求辅助列是有**业务意图**的——让人肉眼能在表里看到"是/否"列;条件格式只是视觉辅助。一步 expression 虽然效果对了,但用户打开表格看不到辅助列,被视为"操作不完整/未采用公式"。
`expression` 单独使用的场景是:用户**没有**明确要求辅助列、只要"标红符合条件的行"时。
⚠️ **创建条件格式前必须读数据行确认列对应**:仅读首行表头(`+csv-get range="A1:Z1"`)不够——如果表头语义含糊(比如"时间"、"日期"这种多列同义词formula 里引用的列字母可能张冠李戴。必须再读 3-5 行**数据样本**(如 `range="A2:Z6"`)确认:①列名对应的实际值;②字段含义匹配用户描述;③数据类型是日期/数字/文本。特别是比较类条件格式(`=$A2>$B2` 这种),列字母选错整条规则就废了。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+cond-format-list` | read | 对象 |
| `+cond-format-create` | write | 对象 |
| `+cond-format-update` | write | 对象 |
| `+cond-format-delete` | high-risk-write | 对象 |
## Flags
### `+cond-format-list`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--rule-id` | string | optional | 按规则 id 过滤 |
### `+cond-format-create`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--properties` | string + File + Stdin复合 JSON | required | 规则配置 JSON`style`(命中样式,必填)和 `attrs?`(规则参数列表,因 `rule_type` 不同结构而异)/ `has_ref?``rule_type``ranges` 已拎为独立 flag |
| `--rule-type` | string | required | 条件格式规则类型;优先级高于 `--properties` 中同名字段(可选值:`duplicateValues` / `uniqueValues` / `cellIs` / `containsText` / `timePeriod` / `containsBlanks` / `notContainsBlanks` / `dataBar` / `colorScale` / `rank` / `aboveAverage` / `expression` / `iconSet` |
| `--ranges` | string + File + Stdin简单 JSON | required | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--properties` 中同名字段 |
### `+cond-format-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--rule-id` | string | required | 目标规则 id |
| `--properties` | string + File + Stdin复合 JSON | required | 规则配置 JSON结构同 `+cond-format-create``--properties`update 是整组覆盖式 |
| `--rule-type` | string | required | 条件格式规则类型;优先级高于 `--properties` 中同名字段(可选值:`duplicateValues` / `uniqueValues` / `cellIs` / `containsText` / `timePeriod` / `containsBlanks` / `notContainsBlanks` / `dataBar` / `colorScale` / `rank` / `aboveAverage` / `expression` / `iconSet` |
| `--ranges` | string + File + Stdin简单 JSON | required | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--properties` 中同名字段 |
### `+cond-format-delete`
_公共四件套 · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--rule-id` | string | required | 目标规则 id |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+cond-format-create` `--properties` / `+cond-format-update` `--properties`
_创建/更新的条件格式属性_
**顶层字段**
- `rule_type` (enum) — 条件格式规则类型 [duplicateValues / uniqueValues / cellIs / containsText / timePeriod / containsBlanks / notContainsBlanks / dataBar / colorScale / rank / aboveAverage / expression / iconSet] — ⚠️ 已拎为独立 flag `--rule-type`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
- `ranges` (array<string>) — 应用条件格式的 A1 范围列表 — ⚠️ 已拎为独立 flag `--ranges`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
- `style` (object) — 命中规则时应用的单元格样式 { back_color?: string, fore_color?: string, text_decoration?: enum, font?: enum }
- `attrs` (array<oneOf>?) — 规则参数列表
- `has_ref` (boolean?) — 可选
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`XOR
### `+cond-format-list`
```bash
# 列出当前 sheet 全部条件格式规则(拿 rule_id 供 update/delete
lark-cli sheets +cond-format-list --url "..." --sheet-id "$SID"
```
### `+cond-format-create`
`--rule-type` / `--ranges` 是独立 flag不要再放 `--properties``style` / `attrs` 等结构走 `--properties`
```bash
# 重复值高亮
lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \
--rule-type duplicateValues --ranges '["A1:A100"]' \
--properties '{"style":{"back_color":"#FFD7D7"}}'
# 数据条
lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \
--rule-type dataBar --ranges '["B2:B100"]' \
--properties @rule.json
```
### `+cond-format-update`
整组覆盖式:先 `+cond-format-list --rule-id <id>` 拿当前完整配置,改后整组传回。
### `+cond-format-delete`
```bash
lark-cli sheets +cond-format-delete --url "..." --sheet-id "$SID" --rule-id "$RULE_ID" --yes
```
> 一次只删一个 `--rule-id`。要删**多个**条件格式时,先 `+cond-format-list` 拿到各 `rule-id`,再用 `+batch-update` 把多个 `+cond-format-delete` 合并为单次原子提交,不要逐个调用。
### Validate / DryRun / Execute 约束
- `Validate`XOR 公共四件套;`--rule-type` / `--ranges` 必填;`--properties` 必须能解析为合法 JSON`--rule-type` 检查必填子字段(`cellIs``attrs.operator` + `attrs.value``expression``attrs.formula``colorScale``min/mid/max` 配色等);`+cond-format-delete` 强制 `--yes``--dry-run`
- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 conditional_format 请求模板"。
- `Execute`:写后不自动回读;如需确认,自行调用 `+cond-format-list --rule-id <id>` 比对规则 / 范围 / 样式。

View File

@@ -0,0 +1,102 @@
# 飞书表格核心操作:分析、编辑与可视化
## 概览
面向"已有飞书表格"的核心工作流,核心原则:**先了解,再分析或写入,最后验证**。本文是方法论总纲;具体工具的参数细节、边界陷阱在对应子 skill本文用指针引到那里不重复展开。
**三份「通用方法与规范」如何分工**(都不含 shortcut按主题单一归属
- **本文core-operations= 流程与铁律**:端到端工作流 + 全局铁律 + 横切陷阱,是读取入口与枢纽。
- **`lark-sheets-visual-standards` = 样式知识**:配色 / 表头 / 数值格式 / 斑马纹 / 美化决策等"正确视觉输出"的全部标准。
- **`lark-sheets-formula-translation` = 公式知识**:飞书公式书写与 Excel 迁移的全部正确性规则(绝对引用、范围语法、数组语义、不支持函数等)。
> **下面的铁律对所有任务一律生效**,即使你是被索引直接路由进 visual 或 formula 而没经过本文——编辑类任务务必先回到这里过一遍铁律。
## 铁律(所有编辑类任务必须满足,子 skill 不得放宽)
1. **最小改动**:除用户明示要改的单元格 / 列外原表其它单元格、行列结构、Sheet 名、合并区、格式必须 1:1 保持。中间结果优先放原数据**右侧**;会与原数据混淆或要承载透视表 / 图表时才**新建空白 Sheet**。**禁止**擅自删 / 改名 / 隐藏 / 移动**已存在**的 Sheet新建允许节制使用
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,并 `+csv-get` / `+cells-get` / `+<对象>-list` 回读校验。**严禁**只在文本里描述"已完成"、用普通公式 / 文本假装结构化对象、或只给占位而无真实写入。
3. **读全再写,禁止只探前 N 行**:批量填充 / 补齐 / 修正类任务必须先确认**真实数据末行**再写,否则会漏写表尾(高频致命错误)。完整的"按表格形态分流读取 + `current_region` / `has_more` 兜底 + 真实末行确认"流程见 `lark-sheets-read-data` 的「确定数据范围的正确流程」。
4. **公式优先于硬编码**:能用飞书公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找等)一律写公式而非静态值,源数据变化才能自动重算。用户口头的"分列 / 排序 / 求和 / 提取"也要落地为公式或原生工具SORT / `TEXTBEFORE` / `MID` / 透视表 等。Excel 公式迁移、数组语义、不支持函数清单一律以 `lark-sheets-formula-translation` 为唯一权威。
5. **续写 / 扩展必须继承样式**:续写、补齐、复制区块、新增行列时,**禁止**只读值只写值。必须连带 `cell_styles` + `border_styles` + 合并 + 行高一起继承。完整继承清单与做法见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」(`border_styles` 四边最易漏)。
6. **多步写入优先 `+batch-update`**:多个连续写入、或同一工具对多个区域重复调用(多次 merge / resize / cells-set必须合并为单次原子 `+batch-update`。语义与不可嵌套的限制见 `lark-sheets-batch-update`
7. **分组汇总必须用透视表**"按 X 统计 Y / 分组汇总 / 各部门数量金额"必须用 `+pivot-{create|update|delete}`(推荐省略 sheet_id 自动新建子表),**禁止**用 SUMIF / COUNTIF 或本地脚本覆盖原表替代。
8. **任务拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",每点一个 `assert`,全部通过才交付:多维度操作(按部门一/二/三级排序)每维一个 assert多目标删 N 行每目标一个多格式兼容多种日期格式每种至少一个样本范围类A1:H11 加边框)起 / 末行 / 末列三边界都核。只完成第一个要点(只排一级、只删 1 行)属违规。
9. **全量处理要前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,落地前把"预期处理条数"硬编码进代码,处理完 `assert actual == expected`。**严禁**输出"已完成前 N 条,剩余将继续"的半成品。
## 推荐工作流程
1. **规划 skill 清单**:开工前一次性列出本任务要读的子 skill避免读一个调一个本轮已读过的不重复读。本 skill + `lark-sheets-workbook` 几乎每次都要。
2. **了解结构**:先 `+workbook-info` 拿子表列表 / 行列数 / 冻结位置(不可猜测,猜错会越界覆盖);涉及合并 / 隐藏 / 分组 / 行高列宽再用 `lark-sheets-sheet-structure``+sheet-info`
3. **读取数据(按任务类型选路径,细则见 `lark-sheets-read-data`**
| 用户需求语义 | 路径 |
|---|---|
| "完善 / 补齐 / 填空 / 修正所有 XX" / 数据分析 / 清洗 / 大数据集 | **A原生优先**(公式 / `+pivot` / `+filter`,见第 5 步);原生表达不了或更复杂时**分批 `+csv-get` 导出 + 本地脚本处理 + 分批回写**(默认覆盖所有对应数据行,不以用户选区为准;脚本与 CLI 配合见下方「CLI 配合要点」) |
| "查一下 / 看看 / 统计 / 汇总" 等只读 | B`+csv-get` 读到上下文 |
| 需要公式 / 样式 / 批注 | C`+cells-get` |
| 续写 / 扩展 / 完善已有内容 | D`+csv-get` 看结构 + `+cells-get` 读源区样式 + `+sheet-info --include row_heights,merges`(见铁律 5 |
**【高频致命错误】** 对"完善 / 补齐 / 填空"类任务用路径 B 探 10 行就写入,实测会漏写表尾多行。写入前必须按 `lark-sheets-read-data`「确定数据范围的正确流程」确认真实数据末行。按关键字定位区域用 `lark-sheets-search-replace``+cells-search`
4. **理解数据语义(写入前必做)**:读表头 + 3-5 行样本确认各列含义与格式(文本 / 数字 / 日期 / 混合);写公式前先分析样本值格式模式再选提取策略;建透视表前先列清"行字段=分组维度、值字段=聚合指标"。需求模糊时(如"加入加减乘除"未说逻辑)基于表头与已有公式推断,不确定就问用户,禁止臆造业务逻辑。
5. **分析与计算(原生工具优先,代码兜底)**:飞书原生能力能随数据自动更新,**必须优先**
| 用户需求 | 必须用的原生工具 | 禁止用代码替代 |
|---|---|---|
| 按 X 统计 Y、分组汇总 | `+pivot-{create\|update\|delete}` | pandas groupby → `+cells-set` |
| 求和 / 计数 / 平均 / 占比 | 公式SUM/COUNT/AVERAGE | Python 算 → 写静态值 |
| 画图表 / 可视化 | `+chart-{create\|update\|delete}` | matplotlib 画图 |
| 条件高亮 / 色阶 | `+cond-format-{create\|update\|delete}` | 逐单元格设样式 |
| 数据筛选 | `+filter-{create\|update\|delete}` | pandas filter → 覆盖写入 |
| 文本提取 / 转换 | 公式REGEXEXTRACT/TEXT/VALUE | Python 正则 → 写静态值 |
| 查找匹配 | 公式VLOOKUP/INDEX+MATCH | pandas merge → 写静态值 |
**只有以下才用代码**:多步清洗流水线、统计建模、公式试错 3 次仍失败的降级。代码结果回写:大块纯值用 `+csv-put`+ `--start-cell`,必要时自动扩容);少量或需公式 / 样式用 `+cells-set`;能用飞书公式表达的写飞书公式。
6. **写入与修改(细节见 `lark-sheets-write-cells`**`+cells-set``range` 必须落在已有行列范围内、`cells` 二维数组与 `range` 严格同维;表尾追加先用 `+dim-insert` 插行列再写;整列 / 整行同结构的值 / 公式 / 格式用模板单元格 + `--copy-to-range`,禁止逐行 `+cells-set`;多步写入合并为 `+batch-update`;改尺寸先读相邻可见行列当前尺寸再决定 `pixel` / `standard` / `auto`,不要猜数值。
7. **验证**:重新读取受影响区域确认值 / 公式 / 样式 / 批注符合预期;对象类(图表 / 透视表 / 条件格式 / 筛选 / 迷你图 / 浮动图片)重新读对象配置确认;出错先定位错误类型 / 受影响区域 / 根因再修复重验。
## 用本地代码 / 脚本时的 CLI 配合要点
复杂处理——多步清洗、统计建模、批量转换、语义任务的分批编排等——用代码(`python` / `node` 等)解决是完全正当的。原生能力(公式 / `+pivot` / `+filter`)能表达就优先用(可随源数据自动重算);原生表达不了或逻辑更复杂时,放手用代码。下面几条让脚本与 CLI 顺畅配合:
- **解析输出时只读 stdout**CLI 把数据 JSON 写到 stdout、把诊断与警告写到 stderr。解析 JSON 时**不要合并这两条流**(即不要 `2>&1`),否则警告行混进 JSON 会让解析失败。用管道(`lark-cli … | jq …`)或先把 stdout 单独重定向到文件再读;需要诊断信息时把 stderr 另导到一个文件。
- **喂给 CLI 的 CSV / JSON 用 UTF-8、不带 BOM**BOM 会污染首格的值或触发 `invalid character` 解析错;脚本读写文件时显式指定 `encoding='utf-8'`
- **临时文件交给运行时的标准库**:用 `tempfile.gettempdir()` / `os.tmpdir()` 等取临时目录,不要硬编码固定路径;放在用户项目目录之外。
- **命令失败先读错误再调整**:同一条命令失败后不要原样重发;先看 stderr 的报错(参数错误、缺依赖、解释器不可用等)定位原因,再决定换写法、补依赖或退回原生工具。
## 公式策略
- **公式优先于硬编码**(同铁律 4能用公式表达的计算一律写公式源数据变化才能自动重算。
- **写任何公式前先读 `lark-sheets-formula-translation`**:它是公式正确性的唯一权威,覆盖绝对引用(`$`)、飞书范围语法(`H:H` 与工具 A1 表示法的区别、ARRAYFORMULA / 数组语义、Excel 迁移、不支持函数清单等全部规则。本文不再单列这些细则。
## 常见陷阱(铁律已覆盖的不再重复,仅列易漏点)
- **合并单元格**:合并区只有左上角存数据,其余读为空是正常行为;写入只能写左上角,写其它位置会报 `cell ... is inside a merged region`。改合并区先取消再操作。安全操作 5 条与"批量取消用大 range 一次调用"见 `lark-sheets-range-operations`
- **`+dim-insert` 不继承行高**`--inherit-style before/after` 只继承值 / 公式 / 边框,不继承 `row_height`,新行会回落默认高度截断长文本;中间插行填文本前先读相邻行 `row_height`,用 `+batch-update``+rows-resize` 补齐。
- **公式容错**:日期 / 查找 / 数值转换公式用 `IFERROR` 包裹;写完读结果列首 5 + 末 5 行查 `#VALUE!` / `#NAME?` / `#REF!` / `#DIV/0!`;同一方案试错上限 3 次,超了改代码以值写入。
- **循环引用**聚合公式SUM/AVERAGE引用范围不能含目标 cell 自身或其传递依赖。
- **NaN / 空值 / 除零**:空值不直接参与运算;除法用 `IF` / `IFERROR` 防零。
- **排序 / 筛选混合文本列**:带货币符 / 单位 / 表达式的文本列直接排序 / 筛选会按字典序出错,先抽数值到辅助列再处理(细则见 `lark-sheets-range-operations` / `lark-sheets-filter`)。
- **隐藏行列**`+csv-get` 默认 `--skip-hidden=false`(含隐藏行列);设 `true` 只看可见数据,但返回行序号与实际行号不再对应。
- **行号一律取 `[row=N]` 前缀**`+csv-get` 的 CSV 中双引号内换行是单元格内换行不是新行;禁止数 `\n`、禁止用"序号列"当行号(细则见 `lark-sheets-read-data`)。
- **列字母取 `col_indices[j]`**:禁止手数表头逗号定位列(>10 列极易 off-by-one
- **跨 sheet 对象**:图表 / 条件格式 / 透视表 / 浮动图片可能分布在多个子表,操作前先 `+workbook-info` 掌握全局。
- **`+cells-search` 不是万能**:用户说"汇总金额"是操作动作(求和),不是搜索该文本;只在确需定位某文本位置时才用。
## 特殊场景
### 续写 / 复制已有区块格式
核心要求见铁律 5。机制带齐哪些样式字段、怎么采样写入`lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」;样式标准(斑马纹奇偶 / 配色 / 边框层级)见 `lark-sheets-visual-standards` 场景二。本文不再展开。
### NLP 任务处理
任务涉及语义理解、翻译、改写、摘要、分类、抽取、多行聚合时,以 NLP 方式处理,不要用纯规则代码替代语义理解(但可用代码做分批、行号映射、结果拼装与写回)。数据量大时**必须**分批(通常 30 行一批),每批处理完立即写回,不要全处理完再一次写入;单批生成通常不超 300 行,超出时按性质抽样或分批并向用户说明范围;多批写入优先用 `+batch-update` 合并为原子提交。
### 格式处理优先公式
"去除多余零 / 提取数字 / 文本格式转换 / 日期格式化"等清洗,**必须优先用公式**`SUBSTITUTE` / `TEXT` / `VALUE` / `LEFT` / `RIGHT` / `MID` 等):写一个模板 + `--copy-to-range` 即可整列处理,远比逐行修改高效。

View File

@@ -1,133 +0,0 @@
# Sheets Dropdown
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
这份 reference 汇总下拉列表配置:
- `+set-dropdown`
- `+update-dropdown`
- `+get-dropdown`
- `+delete-dropdown`
> **关键规则:** 使用 `multipleValue` 写入前,必须先设置下拉列表;否则值会被当成纯文本。
<a id="set-dropdown"></a>
## `+set-dropdown`
对应命令:`lark-cli sheets +set-dropdown`
```bash
lark-cli sheets +set-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--range "<sheetId>!A2:A100" --condition-values '["选项1", "选项2", "选项3"]'
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--range` | 是 | 范围(如 `<sheetId>!A2:A100` |
| `--condition-values` | 是 | 下拉选项 JSON 数组 |
| `--multiple` | 否 | 是否多选 |
| `--highlight` | 否 | 是否着色 |
| `--colors` | 否 | 颜色 JSON 数组 |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:`code``msg`
<a id="update-dropdown"></a>
## `+update-dropdown`
对应命令:`lark-cli sheets +update-dropdown`
```bash
lark-cli sheets +update-dropdown --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" \
--ranges '["<sheetId>!A1:A100"]' \
--condition-values '["选项A", "选项B"]'
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--ranges` | 是 | 范围 JSON 数组 |
| `--condition-values` | 是 | 选项 JSON 数组 |
| `--multiple` | 否 | 是否多选 |
| `--highlight` | 否 | 是否着色 |
| `--colors` | 否 | 颜色 JSON 数组 |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:`spreadsheetToken``sheetId``dataValidation`
<a id="get-dropdown"></a>
## `+get-dropdown`
对应命令:`lark-cli sheets +get-dropdown`
```bash
lark-cli sheets +get-dropdown --spreadsheet-token "shtxxxxxxxx" \
--range "<sheetId>!A2:A100"
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--range` | 是 | 查询范围 |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:
- `dataValidations[].conditionValues`
- `dataValidations[].ranges`
- `dataValidations[].options.multipleValues`
- `dataValidations[].options.highlightValidData`
- `dataValidations[].options.colorValueMap`
<a id="delete-dropdown"></a>
## `+delete-dropdown`
对应命令:`lark-cli sheets +delete-dropdown`
```bash
lark-cli sheets +delete-dropdown --spreadsheet-token "shtxxxxxxxx" \
--ranges '["<sheetId>!A2:A100", "<sheetId>!C1:C50"]'
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--ranges` | 是 | 范围 JSON 数组 |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:
- `rangeResults[].range`
- `rangeResults[].success`
- `rangeResults[].updatedCells`
## 典型流程
```bash
# 1. 配置下拉
lark-cli sheets +set-dropdown --url "<url>" \
--range "<sheetId>!J2:J100" --condition-values '["选项1","选项2"]' --multiple
# 2. 再写入 multipleValue
lark-cli sheets +write --url "<url>" --sheet-id "<sheetId>" --range "J2" \
--values '[[{"type":"multipleValue","values":["选项1","选项2"]}]]'
```
## 参考
- [cell-data](lark-sheets-cell-data.md#write) — 写入普通单元格数据

View File

@@ -0,0 +1,126 @@
# Lark Sheet Filter View
## 概念回顾
筛选视图是 sheet 内的多份独立筛选配置,每个视图持有自己的 `range``rules`,由独立 `view_id`10 位随机字符串)标识。一个 sheet 可有多个视图,视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者,也不与该 sheet 上可能并存的筛选器filter互相影响。
`+filter-view-{create|update|delete}` 负责视图本身的 CRUDcreate / update / delete视图的"进入 / 退出"(激活态)是本地状态,不在工具语义内。
## 使用场景
读写筛选视图对象。本 reference 覆盖 4 个 shortcut
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看已有筛选视图 | `+filter-view-list` | 获取 sheet 上所有视图(视图名、范围、规则) |
| 创建 / 更新 / 删除筛选视图 | `+filter-view-{create|update|delete}` | create / update / delete 三个独立 shortcut |
典型工作流:先读取现有视图了解配置 → 执行创建 / 更新 / 删除 → **必须再次读取验证结果**
**常见配置错误(必须注意)**
- **视图范围必须覆盖表头行**:视图的 range 必须从表头行开始(如 `A1:F100`),不能只包含数据行
- **更新前先读取**:用户说"调整这个视图"时,先用 `+filter-view-list` 拉到目标视图当前 rules**只改差异列**再回写
- **多次 create 不能复用 view_id**:复用应走 `update`,重复 `create` 会产生新视图
- **筛选不支持正则表达式**:飞书表格筛选器不支持正则表达式,传入正则会当成普通文本处理
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+filter-view-list` | read | 对象 |
| `+filter-view-create` | write | 对象 |
| `+filter-view-update` | write | 对象 |
| `+filter-view-delete` | high-risk-write | 对象 |
## Flags
### `+filter-view-list`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--view-id` | string | optional | 按筛选视图 reference_id 过滤(命中即只返回单个视图) |
### `+filter-view-create`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--properties` | string + File + Stdin复合 JSON | required | 筛选视图规则 JSON`rules?`(列级筛选规则数组)和 `filtered_columns?``range``view_name` 是独立 flag |
| `--range` | string | required | 筛选视图作用的单元格范围A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段create 必填,必须覆盖表头行 |
| `--view-name` | string | optional | 筛选视图名称create 不传时系统自动分配update 不传时保留原名;优先级高于 `--properties` 中同名字段 |
### `+filter-view-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--view-id` | string | required | 目标筛选视图 reference_id |
| `--properties` | string + File + Stdin复合 JSON | required | 筛选视图规则 JSON`rules?``filtered_columns?`update 是整组覆盖式(先 `+filter-view-list` 回读再 patch传空 `rules: []` 清空)。`range``view_name` 是独立 flag |
| `--range` | string | optional | 筛选视图作用的单元格范围A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段update 时省略表示保留当前 range |
| `--view-name` | string | optional | 筛选视图名称create 不传时系统自动分配update 不传时保留原名;优先级高于 `--properties` 中同名字段 |
### `+filter-view-delete`
_公共四件套 · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--view-id` | string | required | 目标筛选视图 reference_id |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+filter-view-create` `--properties` / `+filter-view-update` `--properties`
_create / update 的视图属性_
**顶层字段**
- `view_name` (string?) — 可选 — ⚠️ 已拎为独立 flag `--view-name`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
- `range` (string?) — 视图作用的单元格范围A1 表示法) — ⚠️ 已拎为独立 flag `--range`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
- `rules` (array<object>?) — 列级筛选规则列表,每一项对应一个具体列的筛选条件 each: { column_index: string, conditions: array<oneOf>, filtered_rows?: array<number> }
- `filtered_columns` (array<string>?) — 可选
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`XOR`view_id` 是 10 位随机字符串,每个 sheet 可有多个视图。
### `+filter-view-list`
```bash
# 列出某个 sheet 的全部筛选视图
lark-cli sheets +filter-view-list --url "..." --sheet-id "$SID"
# 按 view_id 精确定位
lark-cli sheets +filter-view-list --url "..." --sheet-id "$SID" --view-id vAbcde1234
```
### `+filter-view-create`
`--range`(必填)/ `--view-name`(可选)是独立 flag`rules``--properties`
```bash
lark-cli sheets +filter-view-create --url "..." --sheet-id "$SID" \
--view-name "活跃用户" --range "A1:F1000" \
--properties '{"rules":[{"column_index":"C","conditions":[{"type":"number","compare_type":"greaterThan","values":[100]}]}]}'
```
> `--range` **必须覆盖表头行**(如 `A1:F1000`),不能只包含数据行;`--view-name` 重名时服务端自动改名。
### `+filter-view-update`
> ⚠️ update 是整组覆盖PUT 语义):`--properties` **必传**,未在请求里出现的 rules / filtered_columns 会被清空。如要保留已有 rules先 `+filter-view-list` 读回再合并写回。`--range` 变更会丢弃已有筛选规则属预期行为rules 跟当前 range 绑定)。重复 `+filter-view-create` 不会复用 view_id会产生新视图。
### `+filter-view-delete`
> ⚠️ 删除**已存在**的视图不可逆;目标 view_id **不存在**时按幂等成功返回(不报错)。先 `--dry-run` 看 view_id 确认。
### Validate / DryRun / Execute 约束
- `Validate`XOR 公共四件套;`+filter-view-create` 校验 `--range` 起始行为表头(第一行);`+filter-view-update` 必须先 `+filter-view-list` 确认 view 存在,`--properties` 必传(整组覆盖式);`+filter-view-delete` 强制 `--yes``--dry-run`
- `DryRun`:输出"将要 POST/PATCH/DELETE 的 view 请求模板",零网络副作用;`--sheet-name` 在 dry-run 输出里生成为 `<resolve:Sheet1>` 占位符。
- `Execute`:写后不自动回读;如需确认,自行调用 `+filter-view-list --view-id <new>` 比对当前 range + rules。

View File

@@ -1,193 +0,0 @@
# Sheets Filter Views
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
这份 reference 汇总筛选视图和筛选条件:
- `+create-filter-view`
- `+update-filter-view`
- `+list-filter-views`
- `+get-filter-view`
- `+delete-filter-view`
- `+create-filter-view-condition`
- `+update-filter-view-condition`
- `+list-filter-view-conditions`
- `+get-filter-view-condition`
- `+delete-filter-view-condition`
<a id="create-filter-view"></a>
## `+create-filter-view`
对应命令:`lark-cli sheets +create-filter-view`
在工作表中创建筛选视图,每个工作表最多 150 个。
```bash
lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --range "<sheetId>!A1:H14"
lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --range "<sheetId>!A1:H14" --filter-view-name "我的筛选"
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--range` | 是 | 筛选范围 |
| `--filter-view-name` | 否 | 显示名称 |
| `--filter-view-id` | 否 | 自定义 10 位字母数字 ID |
输出:`filter_view`
<a id="update-filter-view"></a>
## `+update-filter-view`
对应命令:`lark-cli sheets +update-filter-view`
```bash
lark-cli sheets +update-filter-view --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --range "<sheetId>!A1:J20"
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--filter-view-id` | 是 | 筛选视图 ID |
| `--range` | 否 | 新范围 |
| `--filter-view-name` | 否 | 新显示名称 |
<a id="list-filter-views"></a>
## `+list-filter-views`
对应命令:`lark-cli sheets +list-filter-views`
```bash
lark-cli sheets +list-filter-views --spreadsheet-token "shtxxxxxxxx" --sheet-id "<sheetId>"
```
输出:`items[]``filter_view_id``filter_view_name``range`
<a id="get-filter-view"></a>
## `+get-filter-view`
对应命令:`lark-cli sheets +get-filter-view`
```bash
lark-cli sheets +get-filter-view --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
```
输出:`filter_view`
<a id="delete-filter-view"></a>
## `+delete-filter-view`
对应命令:`lark-cli sheets +delete-filter-view`
```bash
lark-cli sheets +delete-filter-view --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--filter-view-id` | 是 | 筛选视图 ID |
<a id="create-filter-view-condition"></a>
## `+create-filter-view-condition`
对应命令:`lark-cli sheets +create-filter-view-condition`
为筛选视图的指定列创建筛选条件。
```bash
# 数值筛选E 列 < 6
lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>" \
--condition-id "E" --filter-type "number" --compare-type "less" --expected '["6"]'
# 文本筛选G 列以 a 开头
lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>" \
--condition-id "G" --filter-type "text" --compare-type "beginsWith" --expected '["a"]'
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--filter-view-id` | 是 | 筛选视图 ID |
| `--condition-id` | 是 | 列字母,如 `E` |
| `--filter-type` | 是 | `hiddenValue` / `number` / `text` / `color` |
| `--compare-type` | 否 | 比较运算符 |
| `--expected` | 是 | 筛选值 JSON 数组 |
输出:`condition`
<a id="update-filter-view-condition"></a>
## `+update-filter-view-condition`
对应命令:`lark-cli sheets +update-filter-view-condition`
```bash
lark-cli sheets +update-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E" \
--filter-type "number" --compare-type "between" --expected '["2","10"]'
```
参数与创建条件相同,但 `filter-type` / `compare-type` / `expected` 可按需部分更新。
<a id="list-filter-view-conditions"></a>
## `+list-filter-view-conditions`
对应命令:`lark-cli sheets +list-filter-view-conditions`
```bash
lark-cli sheets +list-filter-view-conditions --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
```
输出:`items[]`
<a id="get-filter-view-condition"></a>
## `+get-filter-view-condition`
对应命令:`lark-cli sheets +get-filter-view-condition`
```bash
lark-cli sheets +get-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E"
```
输出:`condition`
<a id="delete-filter-view-condition"></a>
## `+delete-filter-view-condition`
对应命令:`lark-cli sheets +delete-filter-view-condition`
```bash
lark-cli sheets +delete-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E"
```
## 参考
- [dropdown](lark-sheets-dropdown.md) — 需要下拉值配合筛选时
- [cell-data](lark-sheets-cell-data.md#find) — 只查数据时用 `+find`

View File

@@ -0,0 +1,119 @@
# Lark Sheet Filter
## 真对象硬约束 + 数量校验
1. **真对象**:当用户要求"筛选 / 只看 / 仅保留 X"时,**必须**通过 `+filter-{create|update|delete}` 创建真实的筛选器对象。**禁止**用"删除不符合条件的行" / "新建子表只放符合条件的行" / 用 `+cells-set` 覆盖原表来代替——这些做法会让原数据丢失或不可恢复。
2. **筛选数量必校**:执行筛选后**必须**回读,断言 `len(visible_rows) == expected_count``expected_count` 来自先用本地脚本在源数据上独立复现该筛选条件得到的结果数。两者不一致时禁止交付,需排查筛选条件 / 数据列类型问题。
3. **混合文本列禁止字面比较**:筛选 key 是公式文本(如 `1000+200=1200`)或带单位的混合文本时,先在辅助列里抽出纯数值再筛选;不能直接用文本比较。
## 使用场景
读写筛选器对象。本 reference 覆盖 4 个 shortcut
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看已有筛选器 | `+filter-list` | 获取筛选器的范围、规则和条件配置 |
| 创建/更新/删除筛选器 | `+filter-{create|update|delete}` | 对筛选器执行写入操作 |
典型工作流:先读取现有筛选器了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**
**只读场景例外**:用户只是想知道哪些数据满足条件、并不要求修改表格展示时,可以走 `lark-sheets-read-data` 读后文本回答,不必创建筛选器。
**常见配置错误(必须注意)**
- **筛选范围必须覆盖表头行**:筛选器的 range 必须从表头行开始(如 `A1:F100`),不能只包含数据行。缺少表头会导致筛选条件无法正确匹配列
- **更新已有筛选器前先读取**:如果子表上已存在筛选器,直接创建会报错或覆盖原有配置。应先用 `+filter-list` 查看是否存在筛选器,存在时使用 update 而非 create
- **筛选条件的列索引要精确**:筛选条件中的列标识必须与实际数据列精确对应,不要凭猜测填写
- **”调整筛选逻辑”要先读旧配置**:用户说”调整筛选”时,先读取现有筛选器的完整配置,理解当前规则后再修改,不要从零创建
- **创建后必须验证**:调用 `+filter-list` 确认筛选器配置正确且生效
- **筛选不支持正则表达式**:飞书表格筛选器不支持正则表达式,传入正则会当成普通文本处理。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+filter-list` | read | 对象 |
| `+filter-create` | write | 对象 |
| `+filter-update` | write | 对象 |
| `+filter-delete` | high-risk-write | 对象 |
## Flags
### `+filter-list`
_公共四件套 · 系统:`--dry-run`_
_仅含公共 / 系统 flag。_
### `+filter-create`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 筛选范围A1 表示法,含表头行,如 `A1:F1000`);不要重复写入 `--properties` 中的 range 字段 |
| `--properties` | string + File + Stdin复合 JSON | optional | 筛选规则 JSON`rules`(列级筛选规则数组)+ `filtered_columns?`(激活列索引提示)。`--properties` 整体可选——传它时 `rules` 不可为空;不传则只在 `--range` 上建立空筛选器(无列条件)。`range` 是独立 flag不要再放此 JSON 里) |
### `+filter-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--properties` | string + File + Stdin复合 JSON | required | 筛选规则 JSON`rules``filtered_columns?`update 是整组覆盖式(传空 `rules: []` 清空)。`range` 已拎为独立 flag |
| `--range` | string | required | 筛选作用的单元格范围A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段 |
### `+filter-delete`
_公共四件套 · 系统:`--yes`、`--dry-run`_
_仅含公共 / 系统 flag。_
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+filter-create` `--properties` / `+filter-update` `--properties`
_创建/更新的筛选器属性_
**顶层字段**
- `range` (string) — 筛选对象作用的单元格范围A1 表示法) — ⚠️ 已拎为独立 flag `--range`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
- `rules` (array<object>) — 列级筛选规则列表,每一项对应一个具体列的筛选条件 each: { column_index: string, conditions: array<oneOf>, filtered_rows?: array<number> }
- `filtered_columns` (array<string>?) — 可选
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`XOR`filter_id` 等同于 `sheet_id`(每个工作表至多一个筛选器)。
### `+filter-list`
```bash
# 查看当前 sheet 的筛选器配置filter_id 等于 sheet_id
lark-cli sheets +filter-list --url "..." --sheet-id "$SID"
```
### `+filter-create`
`--range` 是独立 flag含表头行`rules``--properties`
```bash
lark-cli sheets +filter-create --url "..." --sheet-id "$SID" \
--range "A1:F1000" \
--properties '{"rules":[{"column_index":"B","conditions":[{"type":"multiValue","compare_type":"equal","values":["北京","上海"]}]}]}'
```
### `+filter-update`
> ⚠️ update 是覆盖式:`--properties` 中传新 `rules` 会替换旧组。如只想加一条,要带上已有的全部条件再追加。必填 `--range`。
### `+filter-delete`
```bash
lark-cli sheets +filter-delete --url "..." --sheet-id "$SID" --yes
```
### Validate / DryRun / Execute 约束
- `Validate`XOR 公共四件套;`+filter-create` 校验 `--range` 至少 2 行(表头 + 至少 1 行数据);`+filter-update` 必须先 `+filter-list` 确认目标存在;`+filter-delete` 强制 `--yes``--dry-run`
- `DryRun`:输出"将要 POST/PATCH/DELETE 的 filter 请求模板"。
- `Execute`:写后不自动回读;如需确认,自行调用 `+filter-list` 查看当前筛选条件 + 已过滤行数。

View File

@@ -0,0 +1,158 @@
# Lark Sheet Float Image
> **单元格图片 vs 浮动图片**:飞书表格有两种图片类型,请根据需求选择正确的工具:
> - **单元格图片**:图片嵌入在单元格内部,随单元格移动,属于单元格内容的一部分。→ 使用 `+cells-set`,在 `rich_text` 中设置 `type: "embed-image"`(见 lark-sheets-write-cells
> - **浮动图片**(本 Skill图片悬浮在单元格上方可自由指定位置、大小和层级不属于任何单元格的内容。→ 使用本 Skill 的 `+float-image-{create|update|delete}`。
## 真对象硬约束
当用户要求"插入图片 / 添加 logo / 放一张图"时,**必须**通过 `+float-image-{create|update|delete}`(浮动图片)或 `+cells-set``embed-image`(单元格图片)创建真实的图片对象。**禁止**只在文本回复中给出图片链接 / 描述图片内容代替插入。判断标准:交付后 `+float-image-list` 或单元格 `rich_text` 必须能读到该图片对象。
## 使用场景
读写**浮动图片**对象(悬浮在单元格上方的图片,不属于单元格内容)。本 reference 覆盖 4 个 shortcut
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看已有浮动图片 | `+float-image-list` | 获取浮动图片的位置、大小和层级配置 |
| 创建/更新/删除浮动图片 | `+float-image-{create|update|delete}` | 对浮动图片执行写入操作 |
典型工作流:先读取现有浮动图片了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**
**常见配置错误(必须注意)**
- **单元格图片 vs 浮动图片选择错误**:如果用户希望图片嵌入单元格内部(随单元格移动),应使用 `+cells-set``rich_text` + `embed-image`,而非本 Skill
- **图片位置参数要精确**:锚点单元格的行列索引和偏移量决定了图片位置,设置不当会导致图片遮挡数据
- **创建后必须验证**:调用 `+float-image-list` 确认图片位置和大小正确
图片来源有三种方式,`+float-image-create` 上三者 **XOR、必给其一**`--image` / `--image-token` / `--image-uri`
- **`--image <本地路径>`(首选,最省事)**直接给本地图片文件路径PNG/JPEG/GIF/BMP/HEIC 等。CLI 会自动把它以 `parent_type=sheet_image` 上传,拿到 file_token 后创建浮动图,**不用你手动上传 / 取 token**。路径规则同其它本地文件 flag必须是当前工作目录内的相对路径绝对路径会被 Validate 拒,`--dry-run` 也会拦)。
- `--image-token`:复用**已存在**的图片 file_token。常见来源`+float-image-list` 返回的 `image_token`(适合"换皮不换位置"复用同一张图);② `+cells-set-image` 成功返回里的 `file_token`(它也是 `sheet_image` 上传句柄)。适合"同一张图复用到多处",省去重复上传。
- `--image-uri`:图片 reference_idimage URI由系统自动转 file_token。
> ⚠️ **`--image` 仅 `+float-image-create` 支持**。`+float-image-update` 换图仍只接受 `--image-token` / `--image-uri`,而且**图片源是 update 唯一可省的部分**——三者全不传则保留原图。但 `--image-name` / `--position-{row,col}` / `--size-{width,height}` 在 update 时和 create 一样**必填**`manage_float_image` 工具强制要求这套核心字段,且 `+float-image-list` 不回传 `image_name` 供 CLI 回填)。要在 update 里换一张本地新图,先用 `+cells-set-image` 上传到任意临时单元格、从返回取 `file_token`,再把它传给 update 的 `--image-token`。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+float-image-list` | read | 对象 |
| `+float-image-create` | write | 对象 |
| `+float-image-update` | write | 对象 |
| `+float-image-delete` | high-risk-write | 对象 |
## Flags
### `+float-image-list`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--float-image-id` | string | optional | 按 id 过滤;省略时列工作表全部 |
### `+float-image-create`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--image-name` | string | required | 图片名称,含扩展名(如 `logo.png` |
| `--image-token` | string | xor | 图片 file_token`--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` |
| `--image-uri` | string | xor | 图片 reference_id`--image-token` 二选一);图片上传链路返回的 reference_id |
| `--position-row` | int | required | 图片左上角所在行0-based |
| `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B` |
| `--size-width` | int | required | 图片宽度(像素) |
| `--size-height` | int | required | 图片高度(像素) |
| `--offset-row` | int | optional | 在 `--position-row` 基础上的行内偏移(像素) |
| `--offset-col` | int | optional | 在 `--position-col` 基础上的列内偏移(像素) |
| `--z-index` | int | optional | 图片 Z 轴层级,控制重叠顺序 |
| `--image` | string | xor | 本地图片路径PNG/JPEG 等CLI 自动上传为 sheet_image 并用返回的 file_token省去手动拿 token与 --image-token / --image-uri 三选一) |
### `+float-image-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--float-image-id` | string | required | 目标图片 id |
| `--image-name` | string | required | 图片名称,含扩展名(如 `logo.png` |
| `--image-token` | string | xor | 图片 file_token`--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` |
| `--image-uri` | string | xor | 图片 reference_id`--image-token` 二选一);图片上传链路返回的 reference_id |
| `--position-row` | int | required | 图片左上角所在行0-based |
| `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B` |
| `--size-width` | int | required | 图片宽度(像素) |
| `--size-height` | int | required | 图片高度(像素) |
| `--offset-row` | int | optional | 在 `--position-row` 基础上的行内偏移(像素) |
| `--offset-col` | int | optional | 在 `--position-col` 基础上的列内偏移(像素) |
| `--z-index` | int | optional | 图片 Z 轴层级,控制重叠顺序 |
### `+float-image-delete`
_公共四件套 · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--float-image-id` | string | required | 目标图片 id |
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`XOR。浮动图片是 sheet 级对象——和单元格内嵌图片不同(后者走 `+cells-set`)。
### `+float-image-list`
```bash
lark-cli sheets +float-image-list --url "..." --sheet-id "$SID"
```
### `+float-image-create`
所有字段拍平为独立 flag图片来源 `--image` / `--image-token` / `--image-uri`(三选一 XOR/ `--image-name` / `--position-{row,col}` / `--size-{width,height}` / `--offset-{row,col}` / `--z-index`
```bash
# 首选直接给本地图片路径CLI 自动上传(无需手动拿 token
# 注意:--image-name 是 required即使路径 basename 已经是 logo.png 也要显式传)
lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \
--image ./logo.png --image-name "logo.png" \
--position-row 2 --position-col B --size-width 300 --size-height 200 --z-index 1
# 用已有 file_token从 +float-image-list 的 image_token 或 +cells-set-image 返回的 file_token
lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \
--image-name "logo.png" --image-token "$TOKEN" \
--position-row 0 --position-col A --size-width 200 --size-height 150
# 用 reference_id图片上传链路返回的 image reference_id与 --image-token 二选一)
lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \
--image-name "logo.png" --image-uri "$IMAGE_URI" \
--position-row 2 --position-col B --size-width 300 --size-height 200 --z-index 1
```
### `+float-image-update`
> **update ≈ create只有图片源可省**`manage_float_image` 工具的 update 要求和 create 相同的核心字段——`--image-name`、`--position-{row,col}`、`--size-{width,height}` **全部必填**;唯一区别是**图片源(`--image-token` / `--image-uri`)可以全省**,省略即保留原图。这**不是**"只发改动字段"的 patch缺任一核心字段会被工具拒绝`+float-image-list` 不回传 `image_name`CLI 无法替你回填)。
>
> 推荐流程:先 `+float-image-list --float-image-id <id>` 回读当前 position / size再带上 `--image-name` 和完整的 position / size 调一次 `+float-image-update`。
```bash
# 调整位置 + 尺寸,保留原图(不传图片源)
lark-cli sheets +float-image-update --url "..." --sheet-id "$SID" \
--float-image-id "$IMG_ID" --image-name "logo.png" \
--position-row 5 --position-col C --size-width 300 --size-height 200
# 换图:额外带 --image-token核心字段同样要给全
lark-cli sheets +float-image-update --url "..." --sheet-id "$SID" \
--float-image-id "$IMG_ID" --image-name "new-logo.png" --image-token "$NEW_TOKEN" \
--position-row 5 --position-col C --size-width 300 --size-height 200
```
### `+float-image-delete`
```bash
lark-cli sheets +float-image-delete --url "..." --sheet-id "$SID" --float-image-id "$IMG_ID" --yes
```
### Validate / DryRun / Execute 约束
- `Validate`XOR 公共四件套;`+float-image-create` 要求 `--image` / `--image-token` / `--image-uri` **恰好给一个**`--position-row/col``--size-width/height` 必填且为合法整数;传 `--image` 时还会校验路径安全(绝对路径 / 越出工作目录会被拒,`--dry-run` 同样拦)。`+float-image-update` 必须 `--float-image-id`,并和 create 一样必填 `--image-name` / `--position-{row,col}` / `--size-{width,height}`(缺任一核心字段本地直接报错,不会静默发 0图片源 `--image-token` / `--image-uri` 可省(省略保留原图),给则二选一;`+float-image-delete` 强制 `--yes``--dry-run`
- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 float_image 请求模板";传 `--image` 时会多打印一步本地图片上传(`POST /open-apis/drive/v1/medias/upload_all``parent_type=sheet_image`)。
- `Execute`:写后不自动回读;如需确认,自行调用 `+float-image-list --float-image-id <id>` 比对新位置 / 尺寸。

View File

@@ -1,125 +0,0 @@
# Sheets Float Images
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
这份 reference 汇总浮动图片相关能力:
- `+media-upload`
- `+create-float-image`
- `+update-float-image`
- `+get-float-image`
- `+list-float-images`
- `+delete-float-image`
<a id="media-upload"></a>
## `+media-upload`
对应命令:`lark-cli sheets +media-upload`
把本地图片上传到指定电子表格的素材空间,返回 `file_token`,供 `+create-float-image` 使用。
```bash
lark-cli sheets +media-upload --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--file ./image.png
```
说明:
- 内部调用 `drive/v1/medias/upload_all`
- `>20MB` 自动分片上传
- `--file` 只能是当前工作目录下的相对路径
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--file` | 是 | 本地图片路径,必须是相对路径 |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:`file_token``file_name``size``spreadsheet_token`
<a id="create-float-image"></a>
## `+create-float-image`
对应命令:`lark-cli sheets +create-float-image`
```bash
lark-cli sheets +create-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>" --float-image-token "boxcnXXXX" \
--range "<sheetId>!A1:A1" --width 200 --height 150
```
关键规则:
- `--float-image-token` 必须来自 `+media-upload`
- `--range` 必须锚定单个单元格
- `width` / `height` 必须 `>=20`
- `offset-x` / `offset-y` 必须 `>=0`
输出:`float_image`
<a id="update-float-image"></a>
## `+update-float-image`
对应命令:`lark-cli sheets +update-float-image`
```bash
lark-cli sheets +update-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>" --float-image-id "fi12345678" \
--width 400 --height 300 --offset-y 20
```
至少需要传一个更新字段:`--range` / `--width` / `--height` / `--offset-x` / `--offset-y`
输出:更新后的 `float_image`
<a id="get-float-image"></a>
## `+get-float-image`
对应命令:`lark-cli sheets +get-float-image`
```bash
lark-cli sheets +get-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>" --float-image-id "fi12345678"
```
输出:`float_image`
<a id="list-float-images"></a>
## `+list-float-images`
对应命令:`lark-cli sheets +list-float-images`
```bash
lark-cli sheets +list-float-images --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>"
```
输出:`items[]`
<a id="delete-float-image"></a>
## `+delete-float-image`
对应命令:`lark-cli sheets +delete-float-image`
```bash
lark-cli sheets +delete-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>" --float-image-id "fi12345678"
```
输出:`code``msg`
## 读取图片内容
上述读接口只返回元数据,不返回图片字节。要读取图片内容,用 `float_image_token` 调:
```bash
lark-cli docs +media-preview --token "<float_image_token>" --output ./image.png
```
## 参考
- [cell-images](lark-sheets-cell-images.md) — 写入到单元格的图片
- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 先获取 `sheet_id`

View File

@@ -0,0 +1,267 @@
# 飞书表格公式生成规则
> **本文定位**:飞书公式正确性的**唯一权威**——书写任何飞书公式、或把 Excel 公式迁移到飞书前,先读本文。涵盖公式书写约定(绝对引用、范围语法)、投影 vs spill、ARRAYFORMULA / 数组语义、高风险引用函数、日期差、不支持函数清单。
> **边界**:本文只讲"公式怎么写对";公式**怎么写入表格**`+cells-set` / 模板单元格 + `--copy-to-range` / 容错回读)见 `lark-sheets-write-cells` 与 `lark-sheets-core-operations`。本文不含 shortcut铁律见 `lark-sheets-core-operations`。
**核心原则:飞书不像 Excel 365 那样默认 spill溢出展开。飞书普通公式遇到区域时默认"投影"(只取当前行/列对应的单个值),必须显式使用 `ARRAYFORMULA` 或原生数组函数才能逐项展开。**
## 公式书写约定(写任何公式都先满足)
- **绝对引用 `$`**:向下 / 向右填充前判断哪些引用要锁定——用户指定的固定 cell`$C$3`)、要固定的数据范围(`$A$2:$B$5`)、锁列不锁行(`$A2`)、锁行不锁列(`B$1`)。填充前检查是否需固定汇率 / 税率 / 查找表 / 权重表,以及同列 / 同行公式结构是否一致。
- **公式字符串用飞书范围语法**:写 `H:H``A2:B5`**禁止** `H2:H` / `2:2`。这与 CLI 工具参数(如 `--range`)的 A1 表示法(`A1:D3``1:1`)写法不同,两者混淆会导致调用失败或公式报错。
## 翻译后必做:代码复现校验
公式语法翻译完之后,**必须**用本地脚本在源数据上独立复现一份"等价计算结果"再写入。流程:
1. **挑 3-5 个代表性输入行**(首行 / 中段 / 末行 / 含空值 / 含异常格式各一)
2. **用 Python 复现 Excel 原公式的语义**(不是飞书译文的语义,而是用户原本想要的结果)
3. **写入飞书译文公式后回读这几行的实际值**
4. **三方对照**`Excel 原公式语义 == Python 复现 == 飞书译文回读值`,全部一致才交付;不一致先排查(数组语义?日期差?范围引用?)
**理由**Excel→飞书的语法翻译很容易在 spill / 数组 / 日期差 / 范围引用上出现等价性偏差,仅靠语法转换通过不足以保证业务结果正确。
## 决策流程
1. 最终结果是**标量**(单值)→ 通常不需要 `ARRAYFORMULA`
2. 最终结果是**一维或二维数组**
- 公式中**包含**飞书原生数组函数(如 FILTER、XLOOKUP、MAP 等)→ 无需加 `ARRAYFORMULA`,数组语义会自动传播到整个公式,包括原生数组函数外层接的标量运算(如 `+1``*100`
- 公式中**不包含**任何原生数组函数,但在对区域做标量计算 → 加 `ARRAYFORMULA(<整个表达式>)`
3. Excel 依赖 `ROW(range)` 逐项驱动 `SUBTOTAL/INDIRECT/OFFSET` → 改用 `MAP(ARRAYFORMULA(ROW(...)), LAMBDA(r, ...))`
4. 内层 `INDEX/INDIRECT/OFFSET` 返回范围,外层 `SUMIF/COUNTIF/SUMIFS` 还要继续吃这些范围 → 改用 `MAP(..., LAMBDA(...))``REDUCE(..., LAMBDA(...))`
5. 公式意图是"对多个区域分别计算再汇总"(例如用 INDIRECT/OFFSET 对每行生成一个范围,再对所有范围聚合)→ 飞书不能直接返回"区域的列表",必须明确降维:用 `VSTACK` 垂直合并、`HSTACK` 水平合并、`TOCOL/TOROW` 展平,或 `REDUCE` 归约成标量
6. 算日期差 → 不要写 `DAY(end-start)`,用 `DAYS``DATEDIF` 或直接 `end-start`
## 飞书的投影行为(不是默认 spill
飞书普通公式对引用区域默认"投影"而不是"spill"
- 单列区域 → 按当前公式所在行取值
- 单行区域 → 按当前公式所在列取值
- 二维区域 → 只有当前公式位置能映射到该区域时才取值,否则报错
- 数组常量 `{...}` 或函数返回矩阵,在普通标量上下文里通常只取左上角
因此:
- `=A1:A2` 在飞书普通公式里不会 spill只会投影到当前行
- `=ABS(A2:B2)` 不会得到一整行,要写 `=ARRAYFORMULA(ABS(A2:B2))`
- `=TRUNC({1.1111,2.222},{1,2})` 要得到一整行,写 `=ARRAYFORMULA(TRUNC({1.1111,2.222},{1,2}))`
## ARRAYFORMULA 使用规则
**前提:以下规则适用于公式中没有任何原生数组函数的情况。** 若公式中已有原生数组函数(如 FILTER、XLOOKUP、MAP 等),数组语义会自动传播到整个公式的求值过程,后续标量运算无需额外包 `ARRAYFORMULA`(见下一节)。
需要加 `ARRAYFORMULA` 的典型场景(公式中无原生数组函数时):
- 算术运算:`+ - * / ^ %`
- 比较运算:`= <> > >= < <=`
- 标量数学函数:`ABS ROUND INT TRUNC MOD LOG LN SQRT SIN COS TAN ...`
- 文本函数:`LEN LEFT RIGHT MID UPPER LOWER TRIM TEXT VALUE ...`
- 日期函数:`YEAR MONTH DAY DATE TIME EDATE EOMONTH ...`
- 条件函数:`IF IFS IFERROR IFNA NOT ISNUMBER ISTEXT ISBLANK ...`
- 引用函数(高风险):`INDEX OFFSET COLUMN ROW MATCH`
### 公式中有原生数组函数时,整个公式已进入数组模式
飞书的数组语义会在整个公式求值过程中累积传播:一旦某个原生数组函数运行,后续所有运算符和函数也会自动逐元素处理,无论它们出现在哪一层。
因此,以下写法**无需**额外包 `ARRAYFORMULA`
- `=FILTER(A2:A10,B2:B10="x")+1`
- `=XLOOKUP(E2:E10,A2:A10,B2:B10)*100`
- `=ABS(FILTER(A2:A10,B2:B10>0))`
- `=MAP(A2:A10,LAMBDA(x,x*2))-1`
对比:**没有原生数组函数**时必须加:
- `=A2:A100*B2:B100``=ARRAYFORMULA(A2:A100*B2:B100)`
- `=IF(A2:A100>0,B2:B100,"")``=ARRAYFORMULA(IF(A2:A100>0,B2:B100,""))`
## 飞书原生数组函数清单
以下函数按数组语义工作,通常**不需要额外包 `ARRAYFORMULA`**
`ARRAYFORMULA` `ARRAY_CONSTRAIN` `BYCOL` `BYROW` `CELL` `CHOOSECOLS` `CHOOSEROWS` `DROP` `EXPAND` `FILTER` `FLATTEN` `FREQUENCY` `GROWTH` `HSTACK` `IMPORTDATA` `IMPORTFEED` `IMPORTHTML` `IMPORTRANGE` `IMPORTXML` `LINEST` `LOGEST` `LOOKUP` `MAKEARRAY` `MAP` `MINVERSE` `MMULT` `MUNIT` `QUERY` `RANDARRAY` `REDUCE` `REGEXEXTRACT` `SCAN` `SEQUENCE` `SORT` `SORTBY` `SORTN` `SPLIT` `SUMPRODUCT` `SWITCH` `TAKE` `TEXTSPLIT` `TOCOL` `TOROW` `TRANSPOSE` `TREND` `UNIQUE` `VSTACK` `WRAPCOLS` `WRAPROWS` `XLOOKUP`
> **注意:`SWITCH` 在飞书里被当作原生数组函数处理,这与 Excel 行为不同,不需要额外包 `ARRAYFORMULA`。**
## IMPORTRANGE 跨工作簿引用限制
`IMPORTRANGE` 跨电子表格引用数据时有两条硬上限:
- **嵌套最多 5 层**:被引用的表里若又用 `IMPORTRANGE` 继续引下一张表,整条引用链最多 5 层。
- **每个工作表最多 100 个 `IMPORTRANGE` 引用**。
超限会让引用失效或报错。设计大量跨表汇总前先估算引用数,必要时先把数据落地到本表再计算。
## INDEX / OFFSET / COLUMN / ROW / MATCH 是高风险函数
这组函数容易让人误以为会自动把多值铺开,但在飞书里不能这样假设。
**高风险信号:**
- 行号 / 列号 / 偏移量本身是数组
- 结果本来应该是一行或一块二维区域
- 外层还有算术、比较、`IF` 等继续处理它
更稳的写法:
- `=ARRAYFORMULA(INDEX(...))`
- `=ARRAYFORMULA(OFFSET(...))`
- `=ARRAYFORMULA(COLUMN(...))`
- `=ARRAYFORMULA(ROW(...))`
**例外:** 如果返回值只是立刻交给聚合函数消费,不需要额外包:
- `=SUM(INDEX(A1:B2,0,1))`
## Excel 隐式逐项求值,飞书里要显式写 MAP
**典型特征:**
- 外层是 `SUMPRODUCT``SUM` 等聚合
- 内层用了 `SUBTOTAL``INDIRECT``OFFSET` 等更偏"单值/单引用"的函数
- Excel 会把中间结果逐项带进去算
- 飞书里直接照抄,往往不能得到同样的逐项语义
同类本质也包括:`INDEX/INDIRECT/OFFSET` 先返回范围,外层再把这些范围交给 `SUMIF``COUNTIF``AVERAGEIF``SUMIFS` 等范围感知函数 —— 飞书里这些外层函数不会自动二次展开内层范围。
这时不要只会补 `ARRAYFORMULA`,要显式写"遍历"。最常用模板:
```excel
=SUMPRODUCT(
MAP(
ARRAYFORMULA(ROW(目标范围)),
LAMBDA(r, 单行计算逻辑)
)
)
```
同类场景也优先考虑 `MAP`
- `INDIRECT("A"&ROW(...))`
- `OFFSET(...,ROW(...)-ROW(...),...)`
- `SUBTOTAL(...)`
- `SUMIF(内层返回范围, ...)`
- `COUNTIF(内层返回范围, ...)`
- `SUMIFS(内层返回范围, ...)`
- 任何"希望对每一行 / 每一列各算一次"的模式
## 多层范围结果与三维以上结果
飞书公式结果只能是二维区域,不能是"数组的数组"。
### 多层范围不能自动二次展开
内层 `INDEX/INDIRECT/OFFSET` 返回的是二维范围,外层还想继续对这些范围做范围计算时,不要假设飞书会"再展开一层"。改用:
- `MAP(..., LAMBDA(...))` 显式逐项算
- `REDUCE(..., LAMBDA(...))` 显式累加/归约
### 真正的三维或更高维结果不能直接返回
典型触发场景:想把多个不同区域或不同条件的结果合并展示,例如:
- 对 A 列、B 列、C 列分别做 FILTER想把三列结果并排展示
- 对多个月份分别生成数据行,想把所有月份上下堆叠展示
飞书无法直接返回"多个区域的集合",必须先决定降维方式:
- 上下堆叠:`=VSTACK(slice1, slice2, slice3)`
- 左右拼接:`=HSTACK(slice1, slice2, slice3)`
- 压成单列:`=TOCOL(...)`
- 压成单行:`=TOROW(...)`
- 只保留聚合值:`=REDUCE(slice1, {slice2,slice3}, LAMBDA(acc,x,acc+x))`
不要替用户"偷定"第三维展示方式;如果用户没有明确说明怎么展示,至少先把结果改写成可见的二维形状。
## 不能机械照抄的 Excel 语法
### `@` 隐式交叉
Excel`=@A1:A10`(强制单值,取当前行对应的值)
飞书没有 `@` 运算符。飞书普通公式对引用区域默认就有投影语义,去掉 `@` 即可:
- Excel: `=@A1:A10`
- 飞书: `=A1:A10`
### `#` spill range
Excel`=A1#`(引用 A1 公式溢出的整片区域)
飞书没有此语法,迁移方式:
- spill 区域已知 → 改成明确范围
- spill 区域未知 → 回到源公式重写,或用 `TAKE` / `DROP` / `ARRAY_CONSTRAIN`
### 结构化引用
Excel`=SUM(Table1[Amount])`
飞书不支持结构化引用,改成显式 A1 区域:`=SUM(A2:A100)`
### 老式 CSE 花括号
Excel`{=A1:A10*B1:B10}`Ctrl+Shift+Enter 输入)
飞书改为:`=ARRAYFORMULA(A1:A10*B1:B10)`
## 日期序列与日期差
飞书日期序列:`0 = 1899-12-30``1 = 1899-12-31`,没有 Excel 的 1900 年闰年兼容问题。
**高频错误写法(不要用):**
- `=DAY(B2-A2)` ✗ — 差值会被当成日期序列号再拆字段
- `=MONTH(B2-A2)`
- `=YEAR(B2-A2)`
**正确写法:**
- 天数差:`=DAYS(B2,A2)``=DATEDIF(A2,B2,"D")``=B2-A2`
- 月份差:`=DATEDIF(A2,B2,"M")`
- 年份差:`=DATEDIF(A2,B2,"Y")`
- 工作日差:`=NETWORKDAYS(A2,B2)`
## 飞书不支持的函数
> 本段是"飞书不支持函数"的**唯一权威清单**`lark-sheets-core-operations` 不再单列,统一指向这里)。以下函数在飞书里不存在或被禁用,禁止主动使用;用户明确要求时应拒绝并提供替代方案:
- `STOCKHISTORY` — 实时股票数据,飞书无等价函数,需手动导入数据
- `WEBSERVICE` — 外部 HTTP 请求,飞书无等价函数
- CUBE 系列(`CUBEVALUE``CUBEMEMBER``CUBESET``CUBERANK` 等)— OLAP cube 函数,飞书不支持
- `GOOGLEFINANCE``GOOGLETRANSLATE` 等 Google 特有函数 — 无等价函数
- `FORECAST.ETS` 系列(`FORECAST.ETS``FORECAST.ETS.STAT` 等)— 飞书不支持
- `INFO``RTD` — 系统信息 / 实时数据函数,飞书不支持
- `PIVOT` — 用 `+pivot-{create|update|delete}` 透视表对象替代
- `AMORDEGRC``PHONETIC``DETECTLANGUAGE` — 飞书不支持
## 代表性改写示例
- 基础逐项计算
- Excel: `=A2:A100*B2:B100`
- 飞书: `=ARRAYFORMULA(A2:A100*B2:B100)`
- 条件判断
- Excel: `=IF(A2:A100>0,B2:B100,"")`
- 飞书: `=ARRAYFORMULA(IF(A2:A100>0,B2:B100,""))`
- 原生数组函数(无需改动)
- Excel: `=FILTER(A2:C100,B2:B100="East")`
- 飞书: `=FILTER(A2:C100,B2:B100="East")`
- 原生数组函数 + 标量运算(无需改动,数组语义自动传播)
- Excel: `=XLOOKUP(E2:E10,A2:A10,B2:B10)*100`
- 飞书: `=XLOOKUP(E2:E10,A2:A10,B2:B10)*100`
- 高风险引用函数
- Excel: `=INDEX(A1:D2,{2,1},0)`
- 飞书: `=ARRAYFORMULA(INDEX(A1:D2,{2,1},0))`
- 日期差
- 错误: `=DAY(B2-A2)`
- 推荐: `=DAYS(B2,A2)``=DATEDIF(A2,B2,"D")``=B2-A2`
- Excel 隐式逐项求值
- Excel: `=SUMPRODUCT(SUBTOTAL(103,INDIRECT("E"&ROW($E$16:$E$387))))`
- 飞书: `=SUMPRODUCT(MAP(ARRAYFORMULA(ROW($E$16:$E$387)),LAMBDA(row,SUBTOTAL(103,INDIRECT("E"&row)))))`
- 多层范围 / 二次展开
- 错误思路: `=SUMIF(INDIRECT("E"&ROW($E$16:$E$387)),">0")`
- 飞书: `=MAP(ARRAYFORMULA(ROW($E$16:$E$387)),LAMBDA(r,SUMIF(INDIRECT("E"&r),">0")))`
- 三维降二维(保留所有层)
- 飞书: `=VSTACK(slice1,slice2,slice3)``=HSTACK(slice1,slice2,slice3)`
- 三维降二维(只保留聚合值)
- 飞书: `=REDUCE(slice1,{slice2,slice3},LAMBDA(acc,x,acc+x))`

View File

@@ -1,88 +0,0 @@
# 飞书表格公式规则
> 生成或改写飞书电子表格公式时的参考规则。飞书不像 Excel 365 默认 spill普通公式对区域默认“投影”只取当前行/列对应的单值),必须显式使用 `ARRAYFORMULA` 或原生数组函数才能逐项展开。
## 写入方式
公式必须使用对象格式写入(参见 SKILL.md「单元格数据类型」
```bash
--values '[[{"type":"formula","text":"=SUM(A1:A10)"}]]'
```
## ARRAYFORMULA 判断流程
1. 结果是**标量**(单值)→ 不需要
2. 结果是**数组**,且公式中**有**原生数组函数 → 不需要(数组语义自动传播)
3. 结果是**数组**,且公式中**无**原生数组函数,对区域做标量计算 → 加 `ARRAYFORMULA`
```text
# 有原生数组函数,无需包裹
=FILTER(A2:A10,B2:B10="x")+1 ✓
=XLOOKUP(E2:E10,A2:A10,B2:B10)*100 ✓
=MAP(A2:A10,LAMBDA(x,x*2))-1 ✓
# 无原生数组函数,必须包裹
=ARRAYFORMULA(A2:A100*B2:B100) ✓
=ARRAYFORMULA(IF(A2:A100>0,B2:B100,""))✓
```
## 原生数组函数清单(无需 ARRAYFORMULA
`ARRAYFORMULA` `ARRAY_CONSTRAIN` `BYCOL` `BYROW` `CELL` `CHOOSECOLS` `CHOOSEROWS` `DROP` `EXPAND` `FILTER` `FLATTEN` `FREQUENCY` `GROWTH` `HSTACK` `IMPORTDATA` `IMPORTFEED` `IMPORTHTML` `IMPORTRANGE` `IMPORTXML` `LINEST` `LOGEST` `LOOKUP` `MAKEARRAY` `MAP` `MINVERSE` `MMULT` `MUNIT` `QUERY` `RANDARRAY` `REDUCE` `REGEXEXTRACT` `SCAN` `SEQUENCE` `SORT` `SORTBY` `SORTN` `SPLIT` `SUMPRODUCT` `SWITCH` `TAKE` `TEXTSPLIT` `TOCOL` `TOROW` `TRANSPOSE` `TREND` `UNIQUE` `VSTACK` `WRAPCOLS` `WRAPROWS` `XLOOKUP`
## 高风险函数INDEX / OFFSET / ROW / COLUMN / MATCH
行号/列号/偏移量本身是数组时,必须显式包裹:
```text
=ARRAYFORMULA(INDEX(...))
=ARRAYFORMULA(ROW(...))
```
例外:结果直接交给聚合函数消费时不需要:`=SUM(INDEX(A1:B2,0,1))`
## 隐式逐项求值 → MAP/LAMBDA
Excel 中 `SUBTOTAL``INDIRECT``OFFSET` 等在 `SUMPRODUCT` 内会隐式逐行求值,飞书不会。用 `MAP` 显式遍历:
```text
# Excel
=SUMPRODUCT(SUBTOTAL(103,INDIRECT("E"&ROW($E$16:$E$387))))
# 飞书
=SUMPRODUCT(MAP(ARRAYFORMULA(ROW($E$16:$E$387)),LAMBDA(r,SUBTOTAL(103,INDIRECT("E"&r)))))
```
同类场景:`SUMIF/COUNTIF/SUMIFS` 的范围参数来自 `INDIRECT/OFFSET` 时也需要 `MAP`
## 多维结果降维
飞书公式结果只能是二维,不能返回“区域的列表”。合并多个区域时:
| 需求 | 写法 |
|------|------|
| 上下堆叠 | `=VSTACK(a, b, c)` |
| 左右拼接 | `=HSTACK(a, b, c)` |
| 压成单列 | `=TOCOL(...)` |
| 压成单行 | `=TOROW(...)` |
| 归约为标量 | `=REDUCE(init, arr, LAMBDA(acc, x, ...))` |
## 日期差
| 需求 | 正确写法 | 错误写法 |
|------|---------|---------|
| 天数差 | `=DAYS(B2,A2)``=DATEDIF(A2,B2,"D")``=B2-A2` | `=DAY(B2-A2)` |
| 月份差 | `=DATEDIF(A2,B2,"M")` | `=MONTH(B2-A2)` |
| 年份差 | `=DATEDIF(A2,B2,"Y")` | `=YEAR(B2-A2)` |
| 工作日差 | `=NETWORKDAYS(A2,B2)` | — |
## 飞书不支持的 Excel 语法
| Excel 语法 | 飞书替代 |
|-----------|---------|
| `=@A1:A10`(隐式交叉) | `=A1:A10`(飞书默认投影,去掉 `@` |
| `=A1#`spill range | 改成明确范围,或用 `TAKE`/`DROP`/`ARRAY_CONSTRAIN` |
| `=SUM(Table1[Amount])`(结构化引用) | `=SUM(A2:A100)`(改为 A1 区域) |
| `{=A1:A10*B1:B10}`CSE 花括号) | `=ARRAYFORMULA(A1:A10*B1:B10)` |
| `STOCKHISTORY` / `WEBSERVICE` / `CUBE*` | 飞书无等价函数 |

View File

@@ -0,0 +1,166 @@
# Lark Sheet Pivot Table
## 真对象硬约束
当用户要求"透视表 / 分组汇总 / 交叉分析 / 按 X 统计 Y"时,**必须**通过 `+pivot-{create|update|delete}` 创建真实的透视表对象。**禁止**用 `SUMIFS` / `COUNTIFS` 等普通公式 + `+cells-set` 在原表中拼一张"看起来像透视表的汇总表"来代替。判断标准:交付后 `+pivot-list` 必须能返回该对象。
## 使用场景
读写透视表对象。本 reference 覆盖 4 个 shortcut
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看已有透视表 | `+pivot-list` | 获取透视表的结构、数据源和配置 |
| 创建/更新/删除透视表 | `+pivot-{create|update|delete}` | 对透视表执行写入操作 |
典型工作流:先读取现有透视表了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**
## 行/值字段映射(创建前必做)
创建透视表前先识别用户需求中的分组维度和聚合指标,**不要搞反**
- **rows行字段** = 分组维度,即"按什么分组"。例:部门、地区、医生、产品类别
- **values值字段** = 聚合指标,即"统计什么数值"。例:销售额(聚合方式 `sum`)、订单数(聚合方式 `count`
- **columns列字段** = 交叉维度(可选),即"再按什么横向展开"。例:月份、性别
| 用户说 | rows | values | columns |
|--------|------|--------|---------|
| "按部门统计人数" | 部门 | 姓名(`summarize_by: "count"` | — |
| "按医生统计费用和结余" | 主管医生 | 费用(`"sum"`)、结余(`"sum"` | — |
| "各部门男女人数" | 部门 | 姓名(`"count"` | 性别 |
**常见配置错误(必须注意)**
- **数据源范围必须精确**:透视表的数据源范围必须包含表头行,且精确覆盖全部数据行列。范围过大(包含空行/空列)或过小(遗漏数据列)都会导致透视表结果错误
- **行列字段选择要匹配用户意图**:用户说"按商品统计金额"→ 行字段=商品,值字段=金额(`summarize_by: "sum"`)。不要把行列字段搞反
- **聚合类型要匹配**:用户说"统计数量"→ `summarize_by: "count"`"统计总额"→ `"sum"`"统计平均"→ `"average"`。完整合法值:`sum` / `count` / `average` / `max` / `min` / `product` / `countNums` / `stdDev` / `stdDevp` / `var` / `varp` / `distinct` / `median`。默认不要用 `count` 替代 `sum`
- **参数长度限制**:如果透视表配置 JSON 过长(数据源范围跨越大量行列),可能导致工具调用失败。此时应先确认数据范围的精确边界,避免传入过大的 range
- **创建后必须验证**:调用 `+pivot-list` 确认透视表结构正确
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+pivot-list` | read | 对象 |
| `+pivot-create` | write | 对象 |
| `+pivot-update` | write | 对象 |
| `+pivot-delete` | high-risk-write | 对象 |
## Flags
### `+pivot-list`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--pivot-table-id` | string | optional | 按 id 过滤 |
### `+pivot-create`
_公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--properties` | string + File + Stdin复合 JSON | required | JSON{"rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}(数据源走 --source不要再放进 properties.source |
| `--target-position` | string | optional | 透视表落点子表内的起始 cellA1 格式,如 `A1`),映射到顶层 `target_position`,默认 `A1`(值为 A1 时不下发)。它与 `--range` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 |
| `--target-sheet-id` | string | xor | 透视表落点目标子表的 reference_id`--target-sheet-name` 互斥,优先于 --target-sheet-name都不传时自动新建一张子表放置透视表——推荐。与数据源 sheet 区分:数据源 sheet 写在 --source 的 A1 引用里(带 sheet 前缀,形如 `'Sheet1'!A1:D100`)。 |
| `--target-sheet-name` | string | xor | 透视表落点目标子表的名称(与 `--target-sheet-id` 互斥;都不传时自动新建一张子表放置透视表——推荐)。与数据源 sheet 区分:数据源 sheet 写在 --source 的 A1 引用里(带 sheet 前缀,形如 `'Sheet1'!A1:D100`)。 |
| `--source` | string | required | 透视表源数据区域A1 表示法,格式 `'SheetName'!StartCell:EndCell`,如 `'Sheet1'!A1:D100` |
| `--range` | string | optional | 透视表左上角放置位置A1 单值,如 `F1`,仅 create 生效),映射到 `properties.range`;省略时放在落点子表(默认新建子表)的左上角。它与 `--target-position` 都表达落点但落在不同 wire 字段,避免两者同时给冲突值 |
### `+pivot-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--pivot-table-id` | string | required | 目标透视表 id |
| `--properties` | string + File + Stdin复合 JSON | required | 完整或足够完整的配置(先 `+pivot-list --pivot-table-id <id>` 回读再 patch |
### `+pivot-delete`
_公共四件套 · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--pivot-table-id` | string | required | 目标透视表 id |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+pivot-create` `--properties` / `+pivot-update` `--properties`
_创建/更新的透视表属性_
**顶层字段**
- `range` (string?) — 放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效) — ⚠️ 已拎为独立 flag `--range`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
- `source` (string?) — 源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100' — ⚠️ 已拎为独立 flag `--source`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
- `rows` (array<object>?) — 纵向分组字段(行字段) each: { field: string, display_name?: string, sort?: object, filter?: object, condition_filter?: object, …共 6 项 }
- `columns` (array<object>?) — 横向分组字段(列字段) each: { field: string, display_name?: string, sort?: object, filter?: object, condition_filter?: object, …共 6 项 }
- `filters` (array<object>?) — 筛选区域字段(页字段) each: { field: string, display_name?: string, filter?: object, condition_filter?: object, group?: object }
- `values` (array<object>?) — 要汇总的字段(至少需要 1 个) each: { field: string, display_name?: string, summarize_by?: enum, show_data_as?: enum, base_field?: string }
- `auto_fit_col` (boolean?) — 是否自动调整列宽以适应内容
- `show_row_grand_total` (boolean?) — 是否显示行总计(默认 true
- `show_col_grand_total` (boolean?) — 是否显示列总计(默认 true
- `show_subtotals` (boolean?) — 是否显示分类小计(默认 true应用于所有字段
- `repeat_row_labels` (boolean?) — 是否显示重复项标签
- `calculated_fields` (array<object>?) — 计算字段列表 each: { name: string, formula: string, summarize_by?: enum }
- `collapse` (object?) — 行字段展开/折叠状态:字段名 -> 要折叠的项目列表
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,其中 `--sheet-id` / `--sheet-name``+pivot-update` / `+pivot-delete` / `+pivot-list` 上是公共四件套语义(定位透视表所在 sheetXOR 必传一个)。
**`+pivot-create` 例外**placement 选择器用 `--target-sheet-id` / `--target-sheet-name`XOR两个都不传时后端自动新建子表存放产物强烈推荐绝不碰源数据。数据源 sheet 写在 `--source``'SheetName'!Range` 里,不靠 sheet 选择器 flag。
### `+pivot-list`
```bash
lark-cli sheets +pivot-list --url "..." --sheet-id "$SID"
```
### `+pivot-create`
> 数据源 `--source` 必须从表头行开始;空行 / 汇总行会被当作数据参与聚合,需提前用 `+csv-get` 确认起止边界。`--source` 和 `--range` 是独立 flag不要再放 `--properties``rows` / `columns` / `values` 等数组字段走 `--properties`。
>
> **先理清 `+pivot-create` 上 4 个位置类入参(语义不同,别混)**
> - `--source`**必填****源数据**区域,须自带 `Sheet!` 前缀(如 `'Sheet1'!A1:D100`sheet 名按 A1 标准单引号包裹)。源 sheet 的名字在 `--source` 字符串里,**不**通过单独 flag 传。
> - `--target-sheet-id` / `--target-sheet-name`**透视表的落点 sheet**(即产物放哪张子表)。两个互斥(最多传一个),都不传时后端自动新建子表存放产物(强烈推荐)。
> - `--target-position`可选A1 表示法,默认 `A1`):落点 sheet 内的起始 cell映射到顶层 `target_position`。
> - `--range`可选A1 单值,仅 create 生效):跟 `--target-position` 表达同一意图但映射到 `properties.range`**两者不要同时给**。
>
> **落点 3 种策略(互斥,选其一)**
> 1. **默认(强烈推荐)**`--target-sheet-id` / `--target-sheet-name` / `--target-position` / `--range` **全都不传** → 服务端**自动新建子表**存放产物,绝不碰任何已有数据。
> 2. **放进指定的已有子表**:传 `--target-sheet-id <落点子表 id>`(或 `--target-sheet-name`),可选 `--target-position <子表内起点 cell>`。⚠️ **若落点子表就是源数据所在的 sheet**,必须配 `--target-position` 或 `--range` 指向源数据范围**之外**的位置,否则产物默认从 A1 起会盖在源数据上。
> 3. **`--range`**:跟策略 2 等价(同样需要 `--target-sheet-id` / `--target-sheet-name` 指定落点子表,不然落到自动新建子表),只是用 `properties.range` 那条 wire 路径表达位置。同样的覆盖风险,同样需要避开源数据范围。
>
> 一般用策略 1默认新建子表即可零覆盖风险无需任何 `--target-*` / `--range` flag。
```bash
# 策略 1强烈推荐不传任何落点 flag → 后端自动新建子表,零覆盖风险
lark-cli sheets +pivot-create --url "..." \
--source "'Sheet1'!A1:D100" --properties @pivot.json
# 策略 2落进指定的已有目标子表注意目标 sheet ≠ 源 sheet否则要配 --target-position 避开源数据)
lark-cli sheets +pivot-create --url "..." \
--source "'Sheet1'!A1:D100" --target-sheet-id "$DEST_SID" --target-position "A1" --properties @pivot.json
```
### `+pivot-update`
> 不允许改 `--source` / `--range`(透视表创建后位置/数据源固定);只能用 `--properties` 改 rows / columns / values / filters 等。先 `+pivot-list --pivot-table-id <id>` 回读再 patch避免漏字段。
### `+pivot-delete`
```bash
lark-cli sheets +pivot-delete --url "..." --sheet-id "$SID" --pivot-table-id "$PID" --yes
```
### Validate / DryRun / Execute 约束
- `Validate``--url` / `--spreadsheet-token` XOR 必填;`+pivot-{update,delete,list}``--sheet-id` / `--sheet-name` XOR 必填一个;`+pivot-create` 例外(用 `--target-sheet-id` / `--target-sheet-name` 表达落点,两个都可空时触发 backend auto-create 子表,两个都给则报 mutually exclusive`+pivot-create``--source` 必填且必须含表头行;`--properties``rows` / `columns` / `values` 至少非空之一;`+pivot-delete` 强制 `--yes``--dry-run`
- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 pivot 请求模板"+ 预估输出尺寸(行数 × 列数)。
- `Execute`:写后不自动回读;如需确认,自行调用 `+pivot-list --pivot-table-id <id>` 并用 `+csv-get` 抽样读透视产物核对输出尺寸 + 总计行位置。
> ⚠️ pivot 输出包含总计 / 小计行;后续 chart 引用 pivot 时,`snapshot.data.refs` 必须排除这些行(见 `lark-sheets-chart` 的「⚠️ chart 数据源引用 pivot 时必须排除总计行」段)。

View File

@@ -0,0 +1,263 @@
# Lark Sheet Range Operations
## 结构性操作影响面预检(清除 / 合并 / 排序 / 移动前必做)
`+cells-clear``+cells-{merge|unmerge}``+range-{move|copy|fill|sort}`(移动 / 复制 / 排序 / 自动填充)都会让既有引用关系发生偏移或失效。**操作前必须**先确认以下两点;否则禁止执行:
1. **打印当前合并单元格 + 公式引用 + 数据验证范围**:用 `+sheet-info --include merges` + `+cells-get` 抽样目标区域和它周边的公式 / 透视表 / 图表 / 条件格式 / 筛选器的数据源;评估操作后这些引用是否仍指向正确数据。
2. **`+cells-clear` 不得侵入用户授权范围之外**:清除范围只能是用户明示要清的区域;不要顺手清除"看起来没用"的相邻单元格。
排序场景的存储类型识别 + 辅助列抽数值的细则见下方「sort 操作前必读」章节。
## 使用场景
写入。对指定区域执行结构性操作。本 reference 覆盖 9 个 shortcut按 4 类用途组织:
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 清除内容/格式 | `+cells-clear` | "清空"、"删除内容"、"去掉格式" |
| 合并/取消合并单元格 | `+cells-{merge|unmerge}` | "合并单元格"、"取消合并" |
| 调整行高/列宽 | `+rows-resize / +cols-resize` | "加宽列"、"调整行高"、"自适应列宽" |
| 移动/复制/填充/排序 | `+range-{move|copy|fill|sort}` | "移动数据"、"复制到"、"自动填充"、"按某列排序" |
注意:
- 用户说"这行 / 整行 / 首行"时,优先使用整行范围如 `1:1`"这列 / 整列"时使用 `J:J`。不要截断为局部矩形
- 合并后只保留左上角单元格的内容,其余清除。写入合并区域用 `+cells-set` 对左上角单元格操作
- 调整行高列宽时,先读取相邻行列尺寸再决定像素值,不要随意猜测
- `--copy-to-range``+cells-set` 的参数)复制的是值/公式/样式,不含行高列宽。需要统一尺寸时另行调用 `+rows-resize / +cols-resize`
## 写入后列宽自适应(防内容遮挡)
写入文本 / 数值后**必须**主动检查列宽是否适配,否则会出现"内容被截断 / 长数字显示为科学计数法 / 文本溢出被相邻列遮挡"等用户感知问题:
1. **写入后回读最长内容字符数**:用 `+csv-get` 读目标列的实际写入内容,统计最长单元格的字符数(`max(len(cell) for cell in col)`)。汉字按 2 字符宽度估算,半角字母数字按 1 字符。
2. **判定阈值**:当前列宽(用 `+sheet-info --include row_heights,col_widths` 拿)≥ 最长字符数 × 字体宽度系数 + buffer 才算适配。默认列宽 11 通常只够 11 个半角字符或 5-6 个汉字,写长文本前必扩宽。
3. **修复二选一**
- **扩列宽**:用 `+rows-resize / +cols-resize` 把目标列宽设为 `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素(经验值)
- **自动换行**:在 `+cells-set` 时给单元格设置 `cell_styles.word_wrap="auto-wrap"`(可选值:`overflow` / `auto-wrap` / `word-clip`),并用 `+rows-resize / +cols-resize` 调高对应行的行高
4. **新增列默认列宽规则**:新增列宽度 ≥ `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素,**禁止**用默认 11 直接交付。
**典型反例**:默认列宽 11 但内容含 12+ 字符的中文 / 含单位的数值(如 `109.10μmol/L`/ 长数字未设 `number_format` 显示为科学计数法 —— 用户在结果表里看不到完整原值。
**⚠️ 合并单元格安全操作规则**`+cells-{merge|unmerge}` 必读):
1. **先读后写**:操作前必须用 `+sheet-info --include merges``+cells-get` 识别已有合并区域(特征:多个连续单元格中只有左上角有值,其余为空)。
2. **不要对已合并区域重复 merge**:对已合并的区域再次调用 merge 会报错或产生不可预期结果。
3. **修改合并区域的正确顺序**:先 `unmerge` → 修改内容/样式 → 再 `merge`
4. **对合并区域设置样式**:只对完整 range 设置一次 `cell_styles`(写在左上角单元格),其余位置用 `{}` 占位。
5. **新增合并时数据保护**:合并前确认目标区域只有左上角有数据,其余单元格为空,否则合并会导致非左上角的数据丢失。
6. **批量取消合并一次调用即可**:当一个范围(整列 `A:A`、整行 `3:3`、矩形 `A1:D100`)内存在多个合并区域,直接调一次 `+cells-unmerge` 传入这个大范围,会一次性取消该范围内所有合并区域;**不要**为每个合并区域单独调用 unmerge也不要用 `+batch-update` 拆成多次 unmerge。
**⚠️ 批量操作必须用 `+batch-update`**:对**多个**不同区域执行 `+cells-merge``+rows-resize / +cols-resize` 时,禁止逐个调用,合并为单次原子 `+batch-update`(语义与 `--operations` 入参格式见 `lark-sheets-batch-update`)。
**唯一例外**`+cells-unmerge` 原生支持传一个大 range 一次性取消其中所有合并区域,应直接单次调用,**不要**拆进 `+batch-update`
**⚠️ sort 操作前必读:确认目标列的数据类型**
排序按单元格的**存储类型**比较:纯数字按数值排序;文本字符串按**字典序**`"1000"` 排在 `"999"` 之前,与数值相反);日期按时间戳排序。
以下形态**看起来像数字但实际是字符串**,直接 sort 会得到错误结果:
| 示例 | 说明 |
|------|------|
| `843688.69+20042.35=863731.04` | 表达式文本(无前导 `=` 不是公式,整串按字典序比较) |
| `¥1,234.56` / `$1,234` | 带货币符号 |
| `1.2万` / `3.5亿` / `100kg` | 带中文 / 英文单位 |
| 前后含空格或不可见字符的数字串 | 被当文本 |
| 同列混文本和数字 | 排序后分块 |
**硬性流程**
1. sort 前先用 `+csv-get` 抽样目标列的前 35 行确认原始值形态,不要只看列名和用户问题就直接排。
2. 若是纯数字或日期 → 直接 sort。
3. 若是带符号 / 表达式 / 单位的文本 → **不要直接排**
- 简单场景(货币、千分位、单位前缀):新增辅助列,用公式提取数值(如 `=VALUE(SUBSTITUTE(SUBSTITUTE(A2,"¥",""),",",""))`),按辅助列排序,排完可按需清除辅助列。
- 复杂场景(多段表达式、中文单位、混合格式):分批 `+csv-get` 读到本地,按数值排序后用 `+csv-put` / `+cells-set` 分批回写。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+cells-clear` | high-risk-write | 单元格 |
| `+cells-merge` | write | 单元格 |
| `+cells-unmerge` | write | 单元格 |
| `+rows-resize` | write | 工作表 |
| `+cols-resize` | write | 工作表 |
| `+range-move` | write | 区域 |
| `+range-copy` | write | 区域 |
| `+range-fill` | write | 区域 |
| `+range-sort` | write | 区域 |
## Flags
### `+cells-clear`
_公共四件套 · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 清除范围A1 格式) |
| `--scope` | string | optional | 清除范围 enum`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all` |
### `+cells-merge`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 待合并 / 取消合并的范围A1 格式) |
| `--merge-type` | string | optional | 合并方向(仅 `+cells-merge`)(可选值:`all` / `rows` / `columns`)(默认 `all` |
### `+cells-unmerge`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 待合并 / 取消合并的范围A1 格式) |
### `+rows-resize`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--type` | string | required | 尺寸方式 enum`pixel`(指定 px 像素值,需配 `--size`/ `standard`(重置为默认标准行高)/ `auto`(自动适应内容)(可选值:`pixel` / `standard` / `auto` |
| `--size` | int | optional | 行高像素30 / 40 / 60`--type pixel` 时必填,其它 type 忽略 |
| `--range` | string | required | 要调整行高的行闭区间1-based 行号如 `2:10` 或单行 `5` |
### `+cols-resize`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--type` | string | required | 尺寸方式 enum`pixel`(指定 px 像素值,需配 `--size`/ `standard`(重置为默认标准列宽)(可选值:`pixel` / `standard` |
| `--size` | int | optional | 列宽像素80 / 120 / 200`--type pixel` 时必填,其它 type 忽略 |
| `--range` | string | required | 要调整列宽的列闭区间;列字母如 `A:E` 或单列 `C` |
### `+range-move`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--source-range` | string | required | 源 A1 范围 |
| `--target-sheet-id` | string | optional | 目标子表 id省略时同源 sheet |
| `--target-range` | string | required | 目标 A1 范围(传起点 cell 即可,按源尺寸自动推断) |
### `+range-copy`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--source-range` | string | required | 源 A1 范围 |
| `--target-sheet-id` | string | optional | 目标子表 id省略时同源 sheet |
| `--target-range` | string | required | 目标 A1 范围(传起点 cell 即可,按源尺寸自动推断) |
| `--paste-type` | string | optional | 粘贴内容(仅 `+range-copy`)(可选值:`values` / `formulas` / `formats` / `all`)(默认 `all` |
### `+range-fill`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--source-range` | string | required | 填充模板范围(系列起始 cells |
| `--target-range` | string | required | 目标填充范围A1 格式) |
| `--series-type` | string | optional | 填充序列类型(可选值:`auto` / `linear` / `growth` / `date` / `copy`)(默认 `auto` |
### `+range-sort`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 排序范围A1 格式;含或不含表头由 `--has-header` 决定) |
| `--sort-keys` | string + File + Stdin复合 JSON | required | JSON 数组:`[{"column":"<列字母>","ascending":<bool>}, ...]` |
| `--has-header` | bool | optional | 第一行是表头不参与排序,默认 false |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+range-sort` `--sort-keys`
_排序条件列表(仅 sort 操作)_
**数组项**(类型 object
- `column` (string) — 排序依据的列字母(如 "C"、"D"),必须在 range 范围内
- `ascending` (boolean) — 是否升序排序
## Examples
> ⚠️ 本 skill 派生的 shortcut 跨 3 个分组:`+rows-resize` / `+cols-resize` → 工作表,`+cells-*` → 单元格,`+range-*` → 区域。skill 视角统一在这里讲解。
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`XOR
### `+cells-clear`
> **删不掉嵌入对象**`+cells-clear`(任何 `--scope`,含 `all`)只清单元格的值 / 格式,**删不掉**压在范围内的透视表 / 图表等嵌入对象——后端会报 `can not find embedded block`。删透视表用 `+pivot-delete`、删图表用 `+chart-delete`(先用 `+pivot-list` / `+chart-list` 拿对象 id
> 需要一次清除**多个不连续 range**(如把内容搬走后批量去掉散落各处的边框/底色)时,改用 `lark-sheets-batch-update` 的 `+cells-batch-clear`,避免对 `+cells-clear` 逐个 range 调用。
```bash
# dry-run 先看
lark-cli sheets +cells-clear --url "..." --sheet-id "$SID" --range "A2:Z1000" --scope all --dry-run
# 执行
lark-cli sheets +cells-clear --url "..." --sheet-id "$SID" --range "A2:Z1000" --scope all --yes
```
### `+cells-merge` / `+cells-unmerge`
```bash
# 合并 A1:C1可选 --merge-type all/rows/columns
lark-cli sheets +cells-merge --url "..." --sheet-id "$SID" --range "A1:C1"
# 取消合并:传大 range 一次性取消其中所有合并区域
lark-cli sheets +cells-unmerge --url "..." --sheet-id "$SID" --range "A1:C100"
```
### `+rows-resize` / `+cols-resize`
行高列宽分两条 shortcut避免行 / 列在底层 schema 的差异(行支持 `auto`,列不支持)混在一起。每条 `--type` 必填:
```bash
# 把第 2-10 行设为固定 30 px
lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "2:10" --type pixel --size 30
# 把 A-C 列设为固定 120 px
lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --range "A:C" --type pixel --size 120
# 第 1 行行高自动适应内容(列宽不支持 auto
lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "1" --type auto
# 重置 A-E 列为默认列宽
lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --range "A:E" --type standard
```
> 同时出现在 `lark-sheets-sheet-structure.md` —— 行高 / 列宽调整也算行列结构层动作。
### `+range-move` / `+range-copy`
> `+range-move` 会**清空源区域**move = copy + clear_source`+range-copy` 不动源。
### `+range-fill`
```bash
# 用 A1:A2 的序列规律向下填充到 A3:A100target 区域不能与 source 重叠,否则后端报 source overlaps destination
lark-cli sheets +range-fill --url "..." --sheet-id "$SID" --source-range "A1:A2" --target-range "A3:A100" --series-type auto
```
### `+range-sort`
```bash
# 按 C 列降序排 A1:E100首行为表头不参与
lark-cli sheets +range-sort --url "..." --sheet-id "$SID" --range "A1:E100" --has-header --sort-keys '[{"column":"C","ascending":false}]'
```
### Validate / DryRun / Execute 约束
- `Validate`XOR 公共四件套;`+cells-clear` 强制 `--yes``--dry-run``+range-*` 校验源 / 目标 range 在同一 spreadsheet`+range-sort``--sort-keys` 必须合法 JSON 数组且 col 都在 `--range` 内;`+rows-resize` / `+cols-resize``--type` 必填,`--type pixel``--size` 必填、其它 type 时 `--size` 会被忽略(传了无害);`+cols-resize.--type` 不接受 `auto`(只行高支持自适应)。
- `DryRun`:所有写操作输出"将要 PATCH 的 range + 受影响 cell 数估算"。
- `Execute`:写后不自动回读;如需确认,自行调用 `+cells-get --range <影响范围>` 抽样比对。

View File

@@ -0,0 +1,171 @@
# Lark Sheet Read Data
## 列格式多样性预探(写公式 / 排序 / 筛选前必做)
> 对应 `lark-sheets-core-operations` 的 **R3 计算复现**——本节是 R3 在 read_data 工具层的具体落地。
对参与后续**计算 / 排序 / 筛选 / 公式提取**的列,**必须**先 sample **至少 50 行**(小表则全量),识别该列所有值类型变体后再设计公式 / 条件。只看前 10 行不够,因为下列差异通常潜伏在表尾或中段:
- **日期列同时出现多种格式**`YYYYMM``YYYY-MM-DD``YYYY/M/D`、带时间戳、文本"未知"
- **数值列混入公式文本 / 单位 / 注释**`1000+200=1200``100元``/(合同未明确)``#N/A`
- **空值与 0 / "0" 混杂**
- **大小写 / 全角半角差异**"办公费" vs "办公费 "、"Sales" vs "sales"
预探后必须在公式 / 筛选条件里用 `IFERROR` / `IFS` / 提取数值的辅助列处理所有变体;不能为了通过 head(10) 的样本就直接落地。一旦设计的逻辑只覆盖 sample 中出现的格式,就属于违规。
## 使用场景
读取。从飞书表格中读取单元格数据。本 reference 覆盖 3 个 shortcut按读取目的选择
| 读取目的 | 用这个 shortcut | 数据去向 | 说明 |
|---------|----------------|---------|------|
| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(加 `--rows-json` 改为结构化 rows `{row_number, values:{列字母→值}}`);大表请按 `--range` 行窗口分批读(截断时看 `has_more` |
| 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息token 开销较大 |
| 查看某区域的下拉框(数据验证)选项 | `+dropdown-get` | 对话上下文 | 返回该 A1 范围已配置的下拉列表选项 |
**选择原则**
- 只看值或做数据处理 → `+csv-get`;大表分批读取,避免一次拉全表撑爆上下文
- 要结构化、按 `row_number` / 列字母定位的输出 → `+csv-get --rows-json`(默认 CSV 串更省 token超大表批量仍用默认
- 需要公式/样式/批注 → `+cells-get`
- 只想知道某区域下拉框有哪些选项 → `+dropdown-get`
⚠️ 超大数据请走"`+csv-get``--range` 行窗口(如 `A1:Z500` / `A501:Z1000` …)分批读到本地文件 + 本地脚本处理 + `+csv-put` 分批回写"。
**`+csv-get` 返回值核心设计**
- `annotated_csv`**CSV 数据唯一入口**。每一逻辑行前加 `[row=N] ` 前缀N = 真实表格行号)。任何需要行号的下游操作(合并、写入、清空、格式化、插入/删除、条件格式、筛选、图表/透视表范围、搜索替换等),**行号一律直接从 `[row=N]` 读取**。若需要纯 CSV如喂给本地脚本做解析去前缀即可`line.replace(/^\[row=\d+\] /, '')`
- `col_indices`**定位列字母唯一入口**。在表头中找到目标字段是第 j 个0-based`col_indices[j]` 取列字母。**禁止手数逗号**——列数超过 10 时极易 off-by-one例如把 W 误判为 X
- `row_indices` — 程序化引用的备用数组。LLM 推理请用 `annotated_csv` 的前缀,不要查这个数组里的 index把行号当数值用容易心算出错
- `current_region` — 从请求范围扩展到被空行空列包围的连续数据区域(等价于 Excel Ctrl+Shift+*),适合先读少量行探表头、同时获知整表实际范围。
注意:
- `+csv-get``+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;使用 `+cells-get` 时,在读取 `cells` 之前还必须先看 `warning_message`,并用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置
- 隐藏行列默认包含在返回结果中(`--skip-hidden=false`),如需只看可见数据设为 `true`
**常见配置错误(必须注意)**
- **全量读取导致上下文溢出(高频致命错误)**:不要对大表(数百行以上)直接用 `+csv-get``+cells-get` 读取全部数据到上下文。大表场景必须分批读取:用 `--range` 切行窗口逐块读(`+csv-get` / `+cells-get` 单次返回量由 `--max-chars` 自动兜底,截断时返回 `has_more`);过大时考虑导出到本地文件后用脚本处理再分批回写
- **了解结构 ≠ 读取全量数据**:探表不用读全表,但必须同时探两个方向的表头:
- **横向(列头)**:先读前几行,且**列范围必须覆盖所有列**——用 `+workbook-info` 拿总列数,`range` 末列填到最后一列(例如总列数是 N`range: "A1:[列N]10"`)。列范围截短会遗漏右侧字段、后续写入列定位错误。
- **纵向(行标)**:若左侧 1-2 列是行标签(日期/类别/编号枚举每行含义,典型交叉表/透视布局),**必须再读 `A:A``A:B` 把行标列读到底**,拿全部行标。只读前几行会看不全表尾的行,导致批量写入漏改——这是"只改前 N 行、其余未更新"的主要成因。扁平列表(每行独立记录、列是字段)可跳过这一步,但仍要靠 `current_region` 兜底。
- 数据量大或会进入上下文上限时,分批读 + 本地处理 + 分批回写,不要一口气拉全表到上下文。
- **`+cells-get` 滥用**:当只需要数据值时,使用 `+csv-get`token 开销约为 `+cells-get` 的 1/5。只有确实需要公式、样式或批注时才用 `+cells-get`
- **忽略分页标志**:读取返回 `has_more=true` 时,说明还有更多数据。如果任务需要完整数据,必须继续分页读取,不能只处理第一页就开始写入
- **直接按 `+cells-get` 返回二维数组下标推导真实位置(高频错误)**`ranges[n].cells[i][j]` 里的 `i/j` 只是返回数组下标,不等于真实表格行列。定位真实行号必须用 `ranges[n].row_indices[i]`,定位真实列字母必须用 `ranges[n].col_indices[j]`;若 `--skip-hidden=true`、请求范围越界被裁剪,或最后一行是部分返回,错误地自己数下标会立刻错位
- **CSV 行号计数错误(高频致命错误)**`+csv-get` 返回的 CSV 遵循 RFC 4180 标准,被双引号 `"..."` 包裹的字段中的换行符属于**字段内容的一部分**(即单元格内换行),不代表新的一行。计算行号时必须按**逻辑记录**计数,而非按物理换行符 `\n` 计数
- **手动数列确定列号(高频致命错误)**:禁止通过在 CSV 表头中手动数逗号/字段来确定目标列的列字母。当列数超过 10 时,手动计数极易产生 off-by-one 偏移(例如把 W 列误判为 X 列)。**必须使用 `col_indices`**:先在 CSV 表头中找到目标字段名是第 j 个字段0-based再用 `col_indices[j]` 获取该列的实际列字母
- **用数据列的值推导行号(高频致命错误,常被巧合掩盖)**CSV 中常见"序号 / ID / 编号 / No."等形似行号的列,其值与实际表格行号**没有任何绑定关系**——序号可能跳号1,2,3,5,6...)、可能从非 1 开始、可能有重复或被中途重置。此规则适用于**所有需要行号的下游操作**:合并单元格、区间写入/清空/格式化、插入/删除行、条件格式范围、筛选器范围、图表数据源、透视表范围、搜索替换范围等等——**凡是要把行号填进任何工具参数的场景,行号一律从 `annotated_csv` 中目标行开头的 `[row=N]` 前缀直接读取**,禁止用"序号=行号"、"表头占 1 行所以数据从第 2 行开始"、"第 N 个序号就在第 N+1 行"等心算,也禁止先心算再"事后核对"。**危险特征**:前几十行中序号恰好等于表格行号(典型成因:表头 +1 与一次跳号 -1 的偏移互相抵消形成巧合),模型一旦把这个巧合当作规律,会在后续所有行沿用;而中间再出现跳号时,从该行起整块区域全部错位,且错位不自查很难发现。**正确工作流**:①在 `annotated_csv` 里定位目标逻辑行(按字段内容匹配);②直接读取该行开头的 `[row=N]` 前缀得到真实表格行号;③把这个行号填进下游工具参数。区间操作时,起始行用 start 行的 `[row=N]`、结束行用 end 行的 `[row=N]`。**自检**:动手前,在 `annotated_csv` 靠后位置再抽 1~2 行,核对 `[row=N]` 是否与首列"序号"一致——不一致(典型:`[row=57] 58,...`)即说明有跳号/隐藏行,更要严格从 `[row=N]` 取值,不要被序号列迷惑
- **按 `row_count` 盲读空行(高频低效)**`+workbook-info``row_count` 是 sheet 的**网格物理行数**(常是 200 / 1000 等默认值),不是数据末行;按它把 `--range` 拉到 `S200`(实际数据可能只到 `S32`)会读回大片空行,浪费上下文又干扰判断。真实数据末行以 `+csv-get` 返回的 `current_region` 为准(它就是数据边界),再按下方「确定数据范围的正确流程」确认末行。
- **current_region 当作纯数据范围(高频致命错误)**`current_region` 返回的是从请求范围向四周扩展到被空行空列包围的**连续非空区域**,等价于 Excel 的 Ctrl+Shift+\*。它包含该区域内**所有非空行**——不仅包含数据行,还可能包含标题行、汇总行(如"总计")、签名行(如"编制人/审批人")、脚注等非数据内容。**严禁直接将 `current_region` 的末尾行作为数据范围的结束行**。正确做法见下方「确定数据范围的正确流程」
### 确定数据范围的正确流程(排序、筛选、批量写入等操作前必做)
当后续操作需要精确的数据范围(如排序、筛选、删除、批量写入)时,仅靠 `current_region` 探测到的范围是不够的——必须同时确认数据的**起始行**和**结束行**。具体步骤:
1. **确认起始行**:读取前 5~10 行,识别表头行位置,数据起始行 = 表头行 + 1
2. **确认结束行**(关键步骤,不可跳过):读取 `current_region` 末尾附近的若干行(建议读取末尾 5~10 行),逐行检查内容,排除非数据行:
- **汇总行**:内容为"合计"、"总计"、"小计"、"总计:"等
- **签名/审批行**:内容为"编制人"、"审核人"、"部门负责人"等
- **空行或分隔行**:整行为空或仅有边框
- **备注/脚注行**:注释性文字、说明文字等
3. **最终数据范围** = 起始行 ~ 最后一条有效数据行(排除非数据行)
**示例**`current_region` 返回 `A1:N51`,读取 Row 48~51 发现:
- Row 49: 序号=47, 姓名=xxx, 有正常数据 → ✅ 数据行
- Row 50: "总计", 有合并单元格 → ❌ 汇总行
- Row 51: "总经理:...", "编制人:..." → ❌ 签名行
- **正确数据范围 = A3:N49**(而非 A3:N51
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+cells-get` | read | 单元格 |
| `+dropdown-get` | read | 对象 |
| `+csv-get` | read | 单元格 |
## Flags
### `+cells-get`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | A1 范围,如 `A1:F10`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet |
| `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个(可选值:`value` / `formula` / `style` / `comment` / `data_validation` |
| `--max-chars` | int | optional | 防爆,默认 200000隐藏 flag不在 `--help` 列出,但可正常传入) |
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
### `+dropdown-get`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | A1 范围,如 `A2:A100`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet |
### `+csv-get`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | A1 范围,如 `A1:F30`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet |
| `--max-chars` | int | optional | 防爆,默认 200000隐藏 flag不在 `--help` 列出,但可正常传入) |
| `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` |
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
| `--rows-json` | bool | optional | 返回结构化 rows`{row_number, values:{列字母→值}}`)而非 CSV 文本,默认 `false` |
## Examples
### `+csv-get`
公共四件套:`--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(前两者 XOR后两者 XOR
示例:
```bash
# 简单读sheet 定位必填:--sheet-name 或 --sheet-id 必给一个range 的 Sheet1! 前缀不能替代它)
lark-cli sheets +csv-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet-name "Sheet1" --range "A1:F30"
# 用 sheet-name 模糊定位(运行时框架会先解析到 sheet-id
lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细" --range "A1:F30"
```
输出契约envelope.data
- `annotated_csv` — 含 `[row=N]` 前缀的 CSV 主入口
- `col_indices` / `row_indices` — 列字母 / 行号映射数组
- `current_region` — 自动扩展到非空连续区域的 A1 范围。它是**真实数据边界****优先于 `+workbook-info``row_count`**`row_count` 是网格物理行数,常是 200 / 1000 等默认值、远大于实际数据;按它盲读会拉回大片空行)
- `has_more` — 是否截断;截断后续读用 `--range` 接着读
**加 `--rows-json`:返回结构化 rows而非 CSV 字符串)**
```bash
lark-cli sheets +csv-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet-name "Sheet1" --range "A1:G20" --rows-json
```
`--rows-json` 下的输出契约(替换 `annotated_csv` / `col_indices` / `row_indices`
- `rows` — 数组,每元素 `{row_number, values}``row_number` 是真实表格行号(整数,下游需要行号的操作直接取它);`values` 按**列字母** key`values["D"]`,绝对列字母)。**所有逻辑行都在 `rows` 里**。引号内换行已解析进单元格值,无需自己按 RFC-4180 拆行。
- `data_not_fully_read`**仅当没读全时出现**`{read_through_row, data_extends_through_row, unread_rows, reread_range}`。出现即表示真实数据超出本次读取范围;批量写入前必须按 `reread_range` 重读全区,否则漏行。
- 其余字段(`current_region` / `actual_range` / `has_more`)同上。
### `+cells-get`
示例:
```bash
# 读 A1:F10 的公式 + 样式sheet 定位必填)
lark-cli sheets +cells-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet-name "Sheet1" \
--range "A1:F10" --include formula,style
```
> ⚠️ 调用方在 `cells[i][j]` 中**不能**用下标推真实行列:必须读 `ranges[n].row_indices[i]` / `ranges[n].col_indices[j]`。
### Validate / DryRun / Execute 约束
- `Validate` 阶段只做 XOR 检查、Enum 合法性、防爆参数上限校验;**禁止**联网(如不能用 `--sheet-name` 提前去查 `sheet-id`)。
- `DryRun` 输出请求模板:`--sheet-name` 在 dry-run 输出里生成为 `<resolve:销售明细>` 占位符,不实际解析。
- `Execute` 阶段才进行 sheet-name → sheet-id 解析与 API 调用。

View File

@@ -1,151 +0,0 @@
# Sheets Row and Column Management
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
这份 reference 汇总行列结构操作:
- `+add-dimension`
- `+insert-dimension`
- `+update-dimension`
- `+move-dimension`
- `+delete-dimension`
<a id="add-dimension"></a>
## `+add-dimension`
对应命令:`lark-cli sheets +add-dimension`
在工作表末尾追加空行或空列,不影响已有数据。
```bash
lark-cli sheets +add-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension ROWS --length 10
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--dimension` | 是 | `ROWS``COLUMNS` |
| `--length` | 是 | 追加数量1-5000 |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:`addCount``majorDimension`
<a id="insert-dimension"></a>
## `+insert-dimension`
对应命令:`lark-cli sheets +insert-dimension`
在指定位置插入空行或空列,已有数据向下或向右移动。
```bash
lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension ROWS --start-index 3 --end-index 7
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--dimension` | 是 | `ROWS``COLUMNS` |
| `--start-index` | 是 | 起始位置0-indexed |
| `--end-index` | 是 | 结束位置0-indexed不含 |
| `--inherit-style` | 否 | `BEFORE``AFTER` |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:成功时 `data` 为空对象 `{}`
<a id="update-dimension"></a>
## `+update-dimension`
对应命令:`lark-cli sheets +update-dimension`
更新指定范围行/列的显隐状态和行高/列宽。
```bash
lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension ROWS --start-index 1 --end-index 3 \
--visible=false
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--dimension` | 是 | `ROWS``COLUMNS` |
| `--start-index` | 是 | 起始位置(**1-indexed**,含) |
| `--end-index` | 是 | 结束位置(**1-indexed**,含) |
| `--visible` | 否 | `--visible=true``--visible=false` |
| `--fixed-size` | 否 | 行高或列宽(像素) |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:成功时 `data` 为空对象 `{}`
<a id="move-dimension"></a>
## `+move-dimension`
对应命令:`lark-cli sheets +move-dimension`
将指定范围的行/列移动到目标位置。
```bash
lark-cli sheets +move-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension ROWS \
--start-index 0 --end-index 1 --destination-index 4
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--dimension` | 是 | `ROWS``COLUMNS` |
| `--start-index` | 是 | 源起始位置0-indexed |
| `--end-index` | 是 | 源结束位置0-indexed |
| `--destination-index` | 是 | 目标位置0-indexed |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:成功时 `data` 为空对象 `{}`
<a id="delete-dimension"></a>
## `+delete-dimension`
对应命令:`lark-cli sheets +delete-dimension`
删除指定范围的行或列。
```bash
lark-cli sheets +delete-dimension --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --dimension ROWS --start-index 3 --end-index 7
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 工作表 ID |
| `--dimension` | 是 | `ROWS``COLUMNS` |
| `--start-index` | 是 | 起始位置(**1-indexed**,含) |
| `--end-index` | 是 | 结束位置(**1-indexed**,含) |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:`delCount``majorDimension`
## 参考
- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 查看当前工作表信息
- [cell-style-and-merge](lark-sheets-cell-style-and-merge.md) — 调整样式或合并单元格

View File

@@ -0,0 +1,111 @@
# Lark Sheet Search & Replace
## 替换前 dry-run + 范围明确(替换前必做)
`+cells-replace` 的副作用是不可逆的(除非另写代码回滚)。执行前必须:
1. **明确替换范围**:必须显式说明"只替换 X 列 / X 区域,还是全表替换"。**禁止**默认全表替换——容易误改无关列。范围应由用户指令决定,模糊时主动询问。
2. **dry-run 命中数量**:先用 `+cells-search` 在同一范围、同一关键词、同一匹配选项(大小写 / 精确 / 正则)下统计命中数量。把数量和**期望命中数**(用户明示的或基于业务理解推断的)对照——一致才进入 `+cells-replace`,不一致先排查(关键词太宽?范围太大?)。
3. **替换后回读校验**:执行后再次 `+cells-search` 旧关键词,预期为 0并对替换后的若干代表性单元格回读确认值符合预期。
## 使用场景
读写。在飞书表格中搜索和替换文本。本 reference 覆盖 2 个 shortcut
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 搜索/定位文本 | `+cells-search` | 返回匹配的单元格位置,支持正则、精确匹配等 |
| 查找并替换文本 | `+cells-replace` | 批量替换文本;`--regex` 模式下 `--replacement` 可用 `$1``$2` 引用 `--find` 的捕获组 |
**常见配置错误(必须注意)**
- **不要把操作动词当搜索词**:用户说"汇总金额"是一个操作动作(求和),不是要搜索"汇总金额"这个文本。只有当确实需要定位某个文本值的位置时才用 `+cells-search`
- **不要用搜索来了解表格结构**:要了解表头和数据结构时,应使用 `+csv-get` 读取前几行,而不是用 `+cells-search` 逐个猜测字段名
- **注意正则特殊字符**:使用正则匹配时,`.``*``(``)` 等特殊字符需要转义
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+cells-search` | read | 单元格 |
| `+cells-replace` | write | 单元格 |
## Flags
### `+cells-search`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--find` | string | required | 待查找文本(与 `--regex` 配合时按正则解释) |
| `--range` | string | optional | 查找范围A1 格式);省略时整表 |
| `--match-case` | bool | optional | 大小写敏感 |
| `--match-entire-cell` | bool | optional | 完全匹配整个单元格 |
| `--regex` | bool | optional | 把 `--find` 按正则解释 |
| `--include-formulas` | bool | optional | 也在公式文本中搜索 |
| `--max-matches` | int | optional | 防爆,默认 5000隐藏 flag不在 `--help` 列出,但可正常传入) |
| `--offset` | int | optional | 跳过前 N 个匹配(分页用),默认 0 |
### `+cells-replace`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--find` | string | required | 待替换文本 |
| `--replacement` | string | required | 替换为;传空字符串 `""` 等价于「删除内容」 |
| `--range` | string | optional | 替换范围A1 格式);省略时整表 |
| `--match-case` | bool | optional | 大小写敏感 |
| `--match-entire-cell` | bool | optional | 完全匹配整个单元格 |
| `--regex` | bool | optional | 把 `--find` 按正则解释 |
| `--include-formulas` | bool | optional | 也在公式文本中替换 |
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`XOR 规则)。
### `+cells-search`
示例:
```bash
# 普通查找
lark-cli sheets +cells-search --url "https://example.feishu.cn/sheets/shtXXX" \
--sheet-name "Sheet1" --find "张三"
# 正则 + 范围限定
lark-cli sheets +cells-search --spreadsheet-token shtXXX --sheet-id "$SID" \
--find "^[A-Z]{2}-\\d{4}$" --regex --range "A2:A1000"
```
输出契约envelope.data
- `matches` — 命中 cell 列表,每条含 `address`A1+ `value` + `sheet_id`
- `total_matches` — 匹配总数
- `has_more` / `next_offset` — 分页游标(命中数超过单页上限时用于继续读取)
### `+cells-replace`
示例:
```bash
# 先 dry-run 预览
lark-cli sheets +cells-replace --url "https://example.feishu.cn/sheets/shtXXX" \
--sheet-name "Sheet1" --find "v1" --replacement "v2" --dry-run
# 确认后执行
lark-cli sheets +cells-replace --url "https://example.feishu.cn/sheets/shtXXX" \
--sheet-name "Sheet1" --find "v1" --replacement "v2"
# 正则捕获组:把 "2026-03" 重排成 "03/2026"$1/$2 引用 --find 的捕获组)
lark-cli sheets +cells-replace --url "https://example.feishu.cn/sheets/shtXXX" \
--sheet-name "Sheet1" --regex --find "(\\d{4})-(\\d{2})" --replacement "$2/$1" --dry-run
```
> `+cells-replace` 虽然 Risk = write但范围大或正则错可能改一堆。**强烈推荐工作流**:先 `+cells-search` 看匹配数,再 `+cells-replace --dry-run` 预览,最后真正执行。
### Validate / DryRun / Execute 约束
- `Validate`XOR 公共四件套;`--find` 非空;正则模式下 `--find` 必须是合法正则。
- `DryRun``+cells-search` 输出请求模板;`+cells-replace` 额外返回预估替换数(`would_replace_count`)。
- `Execute`:写后不自动回读;如需确认,自行用 `+cells-search` 复查旧值是否已不再命中。

View File

@@ -1,164 +0,0 @@
# Sheets Sheet Management
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
这份 reference 汇总工作表级操作:
- `+create-sheet`
- `+copy-sheet`
- `+delete-sheet`
- `+update-sheet`
其中 `+create-sheet` / `+copy-sheet` / `+delete-sheet` 底层封装官方“操作工作表operate-sheets”接口`+update-sheet` 封装“更新工作表属性”接口。
<a id="create-sheet"></a>
## `+create-sheet`
对应命令:`lark-cli sheets +create-sheet`
```bash
# 在表格末尾或服务端默认位置创建工作表
lark-cli sheets +create-sheet --spreadsheet-token "shtxxxxxxxx" \
--title "明细"
# 指定插入位置0-based
lark-cli sheets +create-sheet --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--title "汇总" --index 0
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--title` | 否 | 工作表标题,最长 100 字符,不能包含 `/ \ ? * [ ] :` |
| `--index` | 否 | 工作表位置(从 0 开始) |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:
- `spreadsheet_token`
- `sheet.sheet_id`
- `sheet.title`
- `sheet.index`
<a id="copy-sheet"></a>
## `+copy-sheet`
对应命令:`lark-cli sheets +copy-sheet`
```bash
# 按默认位置复制
lark-cli sheets +copy-sheet --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>"
# 指定副本名称和位置
lark-cli sheets +copy-sheet --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>" --title "销售副本" --index 2
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 源工作表 ID |
| `--title` | 否 | 新工作表标题,最长 100 字符,不能包含 `/ \ ? * [ ] :` |
| `--index` | 否 | 新工作表位置(从 0 开始) |
| `--dry-run` | 否 | 仅打印请求,不执行 |
说明:
-`--index`CLI 会先复制,再追加一次位置更新,把副本移动到目标索引
输出:
- `spreadsheet_token`
- `sheet.sheet_id`
- `sheet.title`
- `sheet.index`
<a id="delete-sheet"></a>
## `+delete-sheet`
对应命令:`lark-cli sheets +delete-sheet`
> [!CAUTION]
> 这是**高风险删除操作**。CLI 会要求显式确认;可以先用 `--dry-run` 预览。
```bash
lark-cli sheets +delete-sheet --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>"
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 要删除的工作表 ID |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:
- `deleted`
- `spreadsheet_token`
- `sheet_id`
<a id="update-sheet"></a>
## `+update-sheet`
对应命令:`lark-cli sheets +update-sheet`
用于更新工作表标题、位置、隐藏状态、冻结行列和保护设置。
```bash
# 改名 + 调整冻结
lark-cli sheets +update-sheet --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --title "汇总表" --frozen-row-count 2 --frozen-col-count 1
# 隐藏工作表
lark-cli sheets +update-sheet --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
--sheet-id "<sheetId>" --hidden=true
# 开启保护并授权额外编辑人
lark-cli sheets +update-sheet --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --lock LOCK --lock-info "仅财务维护" \
--user-id-type open_id --user-ids '["ou_xxx","ou_yyy"]'
```
参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 否 | 电子表格 URL`--spreadsheet-token` 二选一) |
| `--spreadsheet-token` | 否 | 表格 token |
| `--sheet-id` | 是 | 要更新的工作表 ID |
| `--title` | 否 | 新标题,最长 100 字符,不能包含 `/ \ ? * [ ] :` |
| `--index` | 否 | 新位置(从 0 开始) |
| `--hidden` | 否 | `--hidden=true` 隐藏,`--hidden=false` 取消隐藏 |
| `--frozen-row-count` | 否 | 冻结行数,`0` 表示取消冻结 |
| `--frozen-col-count` | 否 | 冻结列数,`0` 表示取消冻结 |
| `--lock` | 否 | 保护模式:`LOCK` / `UNLOCK` |
| `--lock-info` | 否 | 保护备注;要求 `--lock LOCK` |
| `--user-id-type` | 否 | `--user-ids` 的 ID 类型:`open_id` / `union_id` / `lark_id` / `user_id` |
| `--user-ids` | 否 | 额外可编辑用户 ID 的 JSON 数组;要求 `--lock LOCK` |
| `--dry-run` | 否 | 仅打印请求,不执行 |
输出:
- `spreadsheet_token`
- `sheet.sheet_id`
- `sheet.title`
- `sheet.hidden`
- `sheet.grid_properties.frozen_row_count`
- `sheet.grid_properties.frozen_column_count`
- `sheet.protect`
## 参考
- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 先获取 `sheet_id`
- [row-column-management](lark-sheets-row-column-management.md) — 需要改行列结构时用这组命令

View File

@@ -0,0 +1,212 @@
# Lark Sheet Sheet Structure
## 结构性操作影响面预检(插入 / 删除行列前必做)
插入 / 删除行列、隐藏 / 取消隐藏、冻结、行列分组都会让原表的引用关系发生偏移。**操作前必须**先打印以下三类信息,并评估操作是否会让它们失效;否则禁止执行:
1. **当前合并单元格范围**(来自 `+sheet-info``merged_cells`):插入行 / 列时,跨过插入位置的合并区域可能扩张或断裂;删除行 / 列时合并区域可能直接消失。
2. **现有公式的引用范围**(用 `+cells-get` 抽样附近行 + 跨表引用 + 透视表 / 图表 / 条件格式 / 筛选器的数据源 range插入 / 删除会导致 `=SUM(B4:B13)` 这种相对引用偏移;如果操作发生在引用范围内部,可能产生 `#REF!`
3. **数据验证(下拉列表)规则的应用范围**:列表来源是某个区域时,区域被部分删除会让规则失效。
不可逆的影响必须先在回复中告知用户,得到确认再执行。
## 使用场景
读写。管理子表结构与布局。本 reference 覆盖 9 个 shortcut按用途分两类
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看子表布局 | `+sheet-info` | 获取行高、列宽、隐藏行列、行列分组、合并单元格等信息 |
| 变更子表结构 | `+dim-{insert|delete|hide|unhide|freeze|group|ungroup|move}` | 插入/删除/隐藏/取消隐藏/冻结/分组/移动行列 |
注意:
- 当表格存在合并单元格时,应结合返回的 `merged_cells` 判断表头、分组标题和区域语义
- 不要把合并区域中非左上角的空白单元格理解为"无内容";通常应将左上角单元格的内容视为整个合并区域的语义内容
- 插入用 `+dim-insert``--position`(插入位置;行用 1-based 行号如 `3`,列用字母如 `C`,新行/列插在此位置**之前**+ `--count`(插入数量,>0。新行/列样式继承用 `--inherit-style``before`/`after`/`none`
- 例如"在第 20 行后新增 116 行"`--position 21 --count 116`"第 20 行后"即 1-based 行号 21
**区间表达统一为 A1 风格**:所有涉及"一段连续行/列"的 shortcut 都用同一套 A1 闭区间字符串语法,**不存在 inclusive / exclusive / 0-based / 1-based 跨命令差异**
| 命令 | 用什么 flag 表达区间 / 位置 | 例子 |
| --- | --- | --- |
| `+dim-insert` | `--position` + `--count` | `--position 3 --count 5`(在第 3 行前插 5 行)/ `--position C --count 2`(在 C 列前插 2 列) |
| `+dim-delete` / `+dim-hide` / `+dim-unhide` / `+dim-group` / `+dim-ungroup` / `+rows-resize` / `+cols-resize` | `--range` | `"3:7"`(第 3-7 行,闭区间)/ `"C:F"`C-F 列,闭区间)/ `"5"``"C"`(单行/列) |
| `+dim-move` | `--source-range`(源区间)+ `--target`(目标位置) | `--source-range "3:7" --target 12`(把第 3-7 行移到第 12 行前)/ `--source-range "C:F" --target H` |
行用 1-based 数字、列用字母——跟 Excel / 飞书 UI 看到的行号、列字母完全一致。
**常见配置错误(必须注意)**
- **插入列直接用字母**`+dim-insert``--position` 在列场景直接传字母(如 `C`),不要把列字母换算成 0-based 索引
- **插入后引用偏移**:插入行/列后,原有数据的行号 / 列字母会发生偏移。如果插入后还需要对原有区域执行写入操作,必须重新计算偏移后的位置
- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `--range` 精确无误。可先用 `+csv-get` 读取目标区域验证内容
- **"在 D 列左侧新增一列"的正确写法**`--position D --count 1`(新列插在 D 列之前);要继承左侧列样式加 `--inherit-style before`
- **`+dim-move` 同维度约束**`--source-range` 是行区间时 `--target` 必须是行号(数字),是列区间时 `--target` 必须是列字母——不可一行一列混用
- **插入列后必须检查多行表头合并区域**:很多表格有 2-3 行的合并表头。插入列后,原有的合并区域不会自动扩展到新列。必须先用 `+sheet-info --include merges` 读取合并区域,插入后将跨越插入位置的合并区域重新设置(用 `+cells-{merge|unmerge}`),否则新列的表头会是空的、格式不连续
- **公式写入范围跳过表头行**:写入公式时从数据行开始(不是第 1 行)。先确认表头占几行(可能 1-3 行),公式的起始行 = 表头行数 + 1
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+sheet-info` | read | 工作表 |
| `+dim-insert` | write | 工作表 |
| `+dim-delete` | high-risk-write | 工作表 |
| `+dim-hide` | write | 工作表 |
| `+dim-unhide` | write | 工作表 |
| `+dim-freeze` | write | 工作表 |
| `+dim-group` | write | 工作表 |
| `+dim-ungroup` | write | 工作表 |
| `+dim-move` | write | 工作表 |
## Flags
### `+sheet-info`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--include` | string_slice | optional | 要返回的结构信息类别,逗号分隔多个(可选值:`merges` / `row_heights` / `col_widths` / `hidden_rows` / `hidden_cols` / `groups` / `frozen` |
| `--range` | string | optional | 限定只返回该 A1 范围的结构信息;省略时返回整表 |
### `+dim-insert`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--inherit-style` | string | optional | 新行/列样式继承策略 enum`before`(继承前一行/列)/ `after`(继承后一行/列)/ `none`(默认)(可选值:`before` / `after` / `none` |
| `--position` | string | required | 插入位置(在此行/列**之前**插入):行用 1-based 行号如 `3`;列用字母如 `C` |
| `--count` | int | required | 插入数量(>0 |
### `+dim-delete`
_公共四件套 · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 要删除的行/列闭区间;行用 1-based 数字如 `3:7` 或单行 `5`,列用字母如 `C:F` 或单列 `C` |
### `+dim-hide`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 要隐藏的行/列闭区间;行如 `3:7`,列如 `C:F` |
### `+dim-unhide`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 要取消隐藏的行/列闭区间;行如 `3:7`,列如 `C:F` |
### `+dim-freeze`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column` |
| `--count` | int | required | 冻结前 N 行/列;传 0 解除冻结 |
### `+dim-group`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--depth` | int | optional | 嵌套分组的层级(创建到第几层),默认 1 |
| `--group-state` | string | optional | 分组初始展开状态(可选值:`expand` / `fold`)(默认 `expand` |
| `--range` | string | required | 要创建分组的行/列闭区间;行如 `3:7`,列如 `C:F` |
### `+dim-ungroup`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--depth` | int | optional | 要取消的分组层级,默认 1最外层 |
| `--range` | string | required | 要取消分组的行/列闭区间;行如 `3:7`,列如 `C:F` |
### `+dim-move`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--source-range` | string | required | 要移动的源行/列闭区间;行如 `3:7`,列如 `C:F` |
| `--target` | string | required | 目标位置(移到此行/列**之前**):行用 1-based 行号如 `12`,列用字母如 `H`。必须与 `--source-range` 同维度(行/列) |
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`XOR
### `+sheet-info`
输出契约:返回子表的行高 / 列宽 / 隐藏 / 合并 / 分组等布局元信息。
### `+dim-insert`
```bash
# 在第 10 行前插 3 行,继承上方样式
lark-cli sheets +dim-insert --url "https://example.feishu.cn/sheets/shtXXX" \
--sheet-id "$SID" --position 10 --count 3 --inherit-style before
# 在 C 列前插 2 列
lark-cli sheets +dim-insert --url "..." --sheet-id "$SID" --position C --count 2
```
### `+dim-delete`
```bash
# 删除第 5-7 行
lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --range "5:7" --yes
# 删除 D-F 列
lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --range "D:F" --yes
```
### `+dim-hide` / `+dim-unhide`
```bash
lark-cli sheets +dim-hide --url "..." --sheet-id "$SID" --range "5:7"
lark-cli sheets +dim-unhide --url "..." --sheet-id "$SID" --range "5:7"
lark-cli sheets +dim-hide --url "..." --sheet-id "$SID" --range "C:F"
```
### `+dim-move`
```bash
# 把第 3-7 行移到第 12 行前
lark-cli sheets +dim-move --url "..." --sheet-id "$SID" --source-range "3:7" --target 12
# 把 C-F 列移到 H 列前
lark-cli sheets +dim-move --url "..." --sheet-id "$SID" --source-range "C:F" --target H
```
### `+rows-resize` / `+cols-resize`
> ⚠️ 这两条 shortcut 来自 `lark-sheets-range-operations` 的 `+rows-resize / +cols-resize` tool分组在"工作表"是为了发现性)。详细参数和示例在 `lark-sheets-range-operations.md`。
>
> 行 vs 列底层 schema 有差异:`+rows-resize.--type` 支持 `pixel` / `standard` / `auto``+cols-resize.--type` 只支持 `pixel` / `standard`(列宽不支持自动适应)。
### `+dim-freeze`
```bash
# 冻结前 1 行(--count 传 0 解除冻结)
lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --dimension row --count 1
```
### `+dim-group` / `+dim-ungroup`(大纲)
> 仅当用户明确说"行分组 / 列分组 / 大纲 / outline"时触发;按字段做数据分组用 `+pivot-create`。
### Validate / DryRun / Execute 约束
- `Validate`XOR 公共四件套;`--range` / `--source-range` 必须是合法 A1 闭区间(行用数字、列用字母,不可混用);`+dim-insert``--count` > 0`+dim-move``--target` 必须与 `--source-range` 同维度(行 vs 列);`+dim-delete` 强制 `--yes``--dry-run``+rows-resize` / `+cols-resize``--type` 必填,`--type pixel``--size` 必填、其它 type 时 `--size` 会被忽略(传了无害);`+rows-resize` / `+cols-resize` 的行 vs 列 `--type` 差异详见 `lark-sheets-range-operations.md`
- `DryRun`:写操作输出"将要 PATCH 的目标范围 + 目标参数"。
- `Execute`:写后不自动回读;如需确认,自行调用 `+sheet-info --include row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen` 查看受影响的范围。

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