Compare commits

..

52 Commits

Author SHA1 Message Date
zhengzhijie
8403e3082b feat(sheets): enable bitable sheet creation 2026-07-09 20:49:44 +08:00
zhengzhijie
34f16ad9c1 fix(sheets): hide bitable sheet creation 2026-07-09 20:49:05 +08:00
zhengzhijiej-tech
f057a0ed1c fix(sheets): pass spreadsheet token to changeset tool (#1839) 2026-07-09 17:59:20 +08:00
xiongyuanwen-byted
4dddb0b0e2 chore(sheets): suppress forbidigo on csv-put stdin pipe detection
os.Stdin.Stat is intentional here - pipe detection needs the real
process fd; IOStreams.In is a plain io.Reader without Stat. Clears the
lint failure left by the stdin-fallback commit.
2026-07-09 16:44:59 +08:00
xiongyuanwen-byted
4b2fec1e5b fix(common): steer rejected @file paths to stdin instead of cd
The absolute-path rejection hint said "cd to the target directory
first" - advice the lark-sheets skill explicitly tells agents not to
follow (it pollutes the working directory). The stdin-contention hint
also demonstrated @file with an absolute path, which would itself be
rejected.

- @file failures on stdin-capable flags now show the equivalent stdin
  invocation (--csv - < /tmp/x.csv)
- the path error recommends a relative path or stdin, not cd
- the stdin-contention example uses a relative @file path

Message-text only; no control-flow change for any domain.
2026-07-09 16:44:59 +08:00
xiongyuanwen-byted
c8abdee931 feat(sheets): make validation errors prescriptive for hot failure modes
Driven by the edit-eval-extra-35Q reports: ~70% of lark-cli sheets
errors were missing-required / JSON-shape / wrong-value classes whose
messages said what broke but not how to fix it, pushing agents into
--help / --print-schema probe loops.

- composite JSON shape errors inline a compact skeleton auto-generated
  from the schema (e.g. --cells -> [[{"value": ...}]]) when the type
  mismatch is shallow container confusion
- +batch-update: missing 'shortcut' shows the entry template; a
  disallowed shortcut inlines the full allow-list; exceeding the
  100-op cap says how many batches to split into; sub-op translator
  failures append the shortcut's complete input-key contract
- +table-put: dtypes/formats keys that miss every column call out the
  A1-letter habit and inline the declared column names; empty cells in
  a date-typed column name the three ways out
- schema enum errors suggest across casing, vocabulary aliases, and
  edit distance
2026-07-09 16:44:48 +08:00
xiongyuanwen-byted
7ee6705490 feat(sheets): add sheets-scoped flag ergonomics via PostMount
Two recovery loops from the edit-eval traces burn agent round-trips:
hallucinated flag names (--cols for --range) whose unknown-flag error
only points at --help, and enum values imported from CSS/Excel
vocabulary ("center" for the vertical alignment Lark spells "middle").

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

Both ride the existing PostMount composition (same pattern as
withTokenAlias), so the common framework is untouched and no other
domain's behavior shifts.
2026-07-09 16:44:33 +08:00
xiongyuanwen-byted
8d2c6c8d40 fix(sheets): sync flag input/enum fixes from sheet-skill-spec
上游修复 spec-table 的 Input/Enum 字符串惯例后重新生成:--widths/--heights 现在带
file/stdin 输入声明,+sheet-create --type 的枚举正确进入 flag defs 与文档。
2026-07-08 15:30:55 +08:00
xiongyuanwen-byted
33e6683c5d feat(sheets): add --widths / --heights map form for per-column/row sizes
从上游 sheet-skill-spec 同步:+cols-resize --widths / +rows-resize --heights 接收
JSON map(键为单行列或闭区间,值为像素或 "standard"/"auto"),CLI 按起始位置排序后
展开为一次原子 batch_update 的多个 resize_range 操作,多列不同宽 / 多行不同高一次
调用完成,不再需要 +batch-update。map 形态与 --range/--width/--height/--type 互斥,
不可作为 +batch-update 子操作嵌入(batch_update 不支持嵌套)。列宽 < 20px 拒绝并提示
Excel 字符单位换算(px ≈ 字符数×8+16);--print-schema --flag-name widths/heights
可查 schema。
2026-07-08 15:22:42 +08:00
xiongyuanwen-byted
b53d2b5330 docs(sheets): 更新 lark-sheets skill 版本至 3.0.2
将 SKILL.md 版本号从 3.0.1 升至 3.0.2,同步近期 sheets
命令改动(+rows-resize/+cols-resize 改 --height/--width、
+csv-put 支持 stdin 回退等)后的技能版本。
2026-07-08 11:22:21 +08:00
xiongyuanwen-byted
c9705ad44d feat(sheets): rework +rows-resize / +cols-resize to --height / --width
从上游 sheet-skill-spec 同步:+cols-resize 用 --width、+rows-resize 用 --height 直接给像素值,
--type 变为可选(省略等价于 pixel)。--type standard/auto 走非像素模式,不能与像素 flag 同传;
--type pixel 与 --width/--height 共存时视为等价形式。--size 已删除。
2026-07-07 16:06:39 +08:00
xiongyuanwen-byted
16738410ef feat(sheets): let +csv-put fall back to piped stdin when --csv is omitted
Agents routinely redirect a CSV into stdin but forget the `--csv -`, so
`+csv-put ... < data.csv` failed its first try on a missing --csv and cost
an extra round-trip (error, then --help, then retry).

Relax --csv's cobra required-gate in the shortcut's PostMount and install a
PreRunE that defaults an omitted --csv to "-" when stdin is a non-interactive
pipe, so the standard stdin-resolution path reads it. The pipe guard keeps an
interactive terminal from blocking on stdin, and a genuine miss (no piped
data) still surfaces csvPutInput's typed "--csv is required" instead of
cobra's bare "required flag(s) ... not set".

Scoped entirely to the sheets domain — no changes to the shared runner or the
flag schema.
2026-07-07 11:31:43 +08:00
xiongyuanwen-byted
0fbdb83f27 feat(sheets): drop pre-refactor aliases from sheets --help listing
The refactored + commands have been the default for over a month. Hide the
deprecated pre-refactor aliases from `sheets --help` via a custom cobra
usage template that skips the deprecated group. Aliases stay registered
and executable: their own `sheets <alias> --help` still shows the
(→ +new-command) pointer, unknown-subcommand suggestions still span them,
and execution still returns the _notice.
2026-07-06 13:23:36 +08:00
zhengzhijiej-tech
a0e56715c7 docs(sheets): sync chart schema and labels guidance (#1716)
* chore(sheets): update chart flag schema

* docs(sheets): clarify chart labels field is presence-toggle, not value-toggle

Synced from sheet-skill-spec. Chart labels (plotArea.plot.labels and per-series
labels) are toggled by object existence — passing labels at all turns data
labels on, even when value/category/series/percentage are all false (server
falls back to showing value). Models repeatedly try `{ value: false, category:
false, series: false }` to disable, which silently shows the value fallback.
The reference doc now spells out both directions: pass labels to show, omit
the whole labels field to hide.

Also picks up earlier spec-side drift not yet propagated:
- pivot-table reference: +pivot-list info return + overlap validation
- flag-defs: cell-matrix fan-out cap default 200000 -> 50000 (#1578)
2026-07-06 11:44:49 +08:00
wenzhuozhen
9a2a437fa0 Merge pull request #1734 from larksuite/feat/sheet-skill-optimize
docs: tighten formula verify workflow guidance
2026-07-06 11:09:56 +08:00
xiongyuanwen-byted
4d795429dc docs(sheets): warn that cells-clear --scope all is irreversibly destructive 2026-07-05 23:50:26 +08:00
xiongyuanwen-byted
97ca586181 docs(sheets): steer import-vs-append onto sheet-copy for existing workbooks 2026-07-05 22:49:53 +08:00
xiongyuanwen-byted
4d5a05db6c docs(sheets): warn that +csv-put silently coerces numeric-looking labels
Add guidance that +csv-put numericizes date-like/ID-like columns whose values are all digits (12.10 becomes 12.1 losing the trailing zero, 001 becomes 1 losing the leading zero); recommend +table-put with dtypes=object/datetime64 or +cells-set + number_format="@". Also fix the batch-update example to use sheet_name instead of sheet_id.
2026-07-05 01:14:46 +08:00
xiongyuanwen-byted
3955bbee8c feat(sheets): let typed writes style blank cells past the data extent
+workbook-create / +table-put apply cell_styles by writing them into the
in-memory matrix, whose size was fixed to the data (cols × rows). A style
range reaching past that extent was rejected as "outside the write range",
so blank cells (reserved regions, decorative headers, empty borders) could
not be styled on the typed --sheets path — only the untyped --values path
padded for it.

Pad the matrix down/right to cover every cell_styles range before applying
(empty cells appended for the uncovered positions), mirroring the --values
behavior. writeSheetData now derives the written width/range from the padded
matrix; both dry-run previews and sheetCreateDims account for the style
extent so the physical grid and the plan match Execute. Ranges above/left of
the write anchor stay rejected (the matrix only grows down/right).
2026-07-03 22:46:39 +08:00
wenzhuozhen
b70ca02067 docs: align formula verify refs with file names 2026-07-03 15:09:13 +08:00
wenzhuozhen
f2f0fe2be7 docs: tighten formula verify workflow guidance 2026-07-03 14:16:32 +08:00
xiongyuanwen-byted
a814c8cb43 fix(sheets): satisfy errs-no-bare-wrap forbidigo and errorlint rules from main
main introduced the errs-no-bare-wrap forbidigo rule and errorlint
coverage that flag 27 issues in existing sheets code after the merge:

- Replace direct *errs.ValidationError type assertions with errors.As
  in sheetsInputStatError and validateSheetMediaUploadFile so wrapped
  errors still match (errorlint).
- Type the embedded flag-schemas.json parse failure as an InternalError
  with cause; it reaches the user directly via --print-schema.
- Annotate genuine intermediate errors (recursive schema validator,
  batch sub-op raw type checks, A1 range/position parsers) with
  //nolint:forbidigo; every caller wraps them into typed flag
  validation errors.
2026-07-03 11:39:03 +08:00
xiongyuanwen-byted
f17b5b1708 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-07-03 11:15:37 +08:00
xiongyuanwen-byted
c378dbfae1 fix(sheets): replace undefined common.FlagErrorf with sheetsValidationForFlag
changesetRevisions called common.FlagErrorf, which does not exist,
breaking the build. Use sheetsValidationForFlag so the errors carry the
offending flag param like the rest of the sheets validation paths.

Also reword two doc comments in lark_sheet_history_revert.go that used
'' for an empty shell string: gofmt (Go 1.19+) rewrites '' in doc
comments to a curly quote, leaving the file permanently unformatted.
2026-07-03 11:14:30 +08:00
xiongyuanwen-byted
fa7e4ffede feat(sheets): accept local_office_ token prefix for image parent_type
The synthetic token prefix for imported office spreadsheets is being
renamed from fake_office_ to local_office_. Accept either prefix when
mapping a spreadsheet token to the drive media parent_type so image
uploads keep working across the rename (main package and backward
compat copy).
2026-07-03 11:14:21 +08:00
xiongyuanwen-byted
df1e7c01dc docs(sheets): dedupe +changeset-get flag def and skill reference entry 2026-07-03 10:22:55 +08:00
zhengzhijiej-tech
6857876d85 Feat/lark sheets develop wzz (#1719)
* feat(sheets): add +changeset-get shortcut for changeset review

Wrap the get_changeset read tool: fetch the raw changeset (edit actions)
between two versions to review whether an AI edit fulfilled the request.
--start-revision required, --end-revision optional (defaults to latest),
gap capped at 100. Adds flag-defs entry + regenerated gen, the ChangesetGet
shortcut + tests, and skill docs.

* feat(sheets): add +get-revision shortcut

Return a spreadsheet's current document revision without pulling the full
sub-sheet listing. +get-revision is a read-only derivative over
get_workbook_structure (the lightest read — token only, no range) that
projects the response down to the single revision field.

Adds flag-defs entries and a unit test for the projection helper.

* feat: 同步 spec 修改

* feat(sheets): rename +get-revision to +revision-get

* feat: 移除 ppe 环境请求头

---------

Co-authored-by: wenzhuozhen <wenzhuozhen@bytedance.com>
2026-07-02 20:03:28 +08:00
xiongyuanwen-byted
7cca3f39cd docs(sheets): tighten number-vs-text guidance and dedupe write-cells reference 2026-07-02 18:40:13 +08:00
xiongyuanwen-byted
f41e6c4d74 docs(sheets): type by data nature, add pre-write reference column and chart/cond-format/filter rows
- SKILL.md quick-reference: add a "read before acting" column pointing each
  intent at its reference doc; add chart / cond-format / filter rows.
- Reframe number-vs-text decision to follow the data's nature (measure vs
  identifier), not whether the current task happens to sort/sum; a
  leaderboard/report "display only" use does not make a percentage text.
- write-cells reference: mirror the same rule and the +cells-set fallback
  for layouts +table-put cannot express.
2026-07-02 13:41:09 +08:00
xiongyuanwen-byted
77dda3ddaa docs(sheets): clarify number-vs-text typing and copy-to-range template guidance in references 2026-07-01 16:22:33 +08:00
xiongyuanwen-byted
4318f57c12 docs(sheets): bump lark-sheets skill version to 3.0.1 2026-07-01 13:01:14 +08:00
xiongyuanwen-byted
082625d2f1 docs(sheets): clarify workbook-import over read-then-recreate in skill 2026-07-01 10:42:32 +08:00
xiongyuanwen-byted
906826d4a1 fix(sheets): lower cells-set --max-cells default to 50000 2026-07-01 10:42:31 +08:00
wenzhuozhen
aa1a065802 Merge pull request #1653 from larksuite/feat/sheet-history-revert
feat(sheets): add +history-list / +history-revert / +history-revert-status shortcuts
2026-06-30 15:22:44 +08:00
wenzhuozhen
017d752ed9 docs(sheets): sync history skill reference required badges from spec
Companion to commit 9fa73312 (transaction-id) and 6ca35b06
(history-version-id): the two flag tables in
skills/lark-sheets/references/lark-sheets-history.md still showed
'optional' even though the canonical contract — and shortcuts/sheets/data/
flag-defs.json — already moved to 'required'. The earlier syncs only
picked up the data file from spec; the skill markdown drift slipped
through. Pull in the spec-side regenerated reference (ee/sheet-skill-spec
@9ca814d) so the human-readable doc matches the wire contract.
2026-06-30 14:42:43 +08:00
wenzhuozhen
909f78ed58 fix(sheets): make +history-revert-status --transaction-id cobra-required (match +history-revert)
Companion to commit 6ca35b06: same gating model now applies to both history
receipts.
- shortcuts/sheets/lark_sheet_history_revert.go: transactionIDFlag.Required=true.
  Validate keeps a trim/empty-string guard for '--transaction-id ""'.
- shortcuts/sheets/data/flag-defs.json: +history-revert-status --transaction-id
  required: optional -> required (synced from sheet-skill-spec @9ca814d).
- shortcuts/sheets/flag_defs_gen.go: regenerated.
- shortcuts/sheets/lark_sheet_history_test.go:
  TestHistoryRevert_MissingRequiredFlag/+history-revert-status moved to the
  cobra "required flag(s)" text contract (the test rig invokes the shortcut
  via cmd.Execute, which sees the raw cobra error directly without the
  dispatcher's typed wrap). Drop now-unused `errors` and `errs` imports.

Validation:
- go test ./shortcuts/sheets/... PASS (sheets + backward)
- TestFlagsFor_EveryRegisteredCommandHasDefs: PASS
- TestFlagDefsGen_MatchesJSON: PASS
- TestHistoryRevert_MissingRequiredFlag (both subtests): PASS
2026-06-30 14:42:43 +08:00
wenzhuozhen
047f0675ac fix(sheets): make +history-revert --history-version-id cobra-required + revert max-cells default drift
Two issues surfaced during MR !37 review:

1) +history-revert --history-version-id requiredness was set as
   "optional" in the spec table (BE-2 fix dc5fe0ea) so cobra wouldn't
   block before Validate. Per upstream review the flag should be
   required-by-cobra so the user gets the standard "required flag(s)"
   gate immediately and the runtime contract matches the JSON shape.
   - shortcuts/sheets/lark_sheet_history_revert.go: historyVersionIDFlag
     now sets Required: true. Validate keeps a trim/empty-string guard
     so '--history-version-id ""' still fails as a typed
     *errs.ValidationError (cobra accepts empty strings as "set").
   - shortcuts/sheets/data/flag-defs.json: +history-revert
     --history-version-id required: optional -> required.
   - shortcuts/sheets/flag_defs_gen.go: regenerated.
   - shortcuts/sheets/lark_sheet_history_test.go:
     TestHistoryRevert_MissingRequiredFlag split into per-shortcut
     subtests; +history-revert asserts cobra's "required flag(s)"
     contract (raw err — the test rig calls cmd.Execute directly so it
     doesn't see the cmd dispatcher's typed envelope wrap);
     +history-revert-status keeps the typed *errs.ValidationError
     contract (its --transaction-id stays cobra-optional + Validate-enforced).

2) max-cells safety cap was accidentally rewritten from 200000 to
   50000 by the last sync from sheet-skill-spec (the spec canonical
   side fell out of date — fixed separately on the spec MR follow-up).
   Restore desc: "Safety cap; default 200000" / default: "200000" so
   +cells-get / +csv-get keep the documented cap.

Validation:
- go test ./shortcuts/sheets/...                                     PASS
- TestHistoryRevert_MissingRequiredFlag (both subtests)              PASS
- TestHistoryShortcuts_DryRun (incl. +history-list pagination case)  PASS
- TestFlagsFor_EveryRegisteredCommandHasDefs                         PASS
- TestFlagDefsGen_MatchesJSON                                        PASS
2026-06-30 14:42:42 +08:00
wenzhuozhen
983c6e72ec feat(sheets): +history-list --end-version for backward pagination
Spec follow-up sheet-history-revert: thread the history_list pagination
contract through the +history-list shortcut.

- shortcuts/sheets/lark_sheet_history_list.go:
  + --end-version (int, optional). Mapped to the tool input's `end_version`
    only when explicitly set (so the server treats absence as
    "first page / latest"), via runtime.Changed / runtime.Int (matches the
    +formula-verify --max-locations precedent).
  + Tip: pass next_end_version from the response on the next call;
    capture exits the pagination loop when the server omits the field.

- shortcuts/sheets/lark_sheet_history_test.go: + dry-run case asserting
  --end-version 12345 lands as input.end_version=12345 (post-JSON
  unmarshal float64).

- skills/lark-sheets/references/lark-sheets-history.md: synced from
  ee/sheet-skill-spec (commit 39c6b61). Adds the "倒序分页" caveat row +
  --end-version flag + pagination Examples line. Drops the internal
  MajorHistory.Version implementation detail per spec follow-up.

- shortcuts/sheets/data/flag-defs.json: synced from spec (+history-list
  +--end-version int optional).

- shortcuts/sheets/flag_defs_gen.go: regenerated via
  `go generate ./shortcuts/sheets/...`.

Companion changes:
- ee/sheet-skill-spec MR !37: spec-tables + tool-schemas pagination
  contract (commits 09e8604, 39c6b61).
- ee/sheet-facade-agg MR !1028: history_list tool plumbs end_version,
  emits next_end_version + has_more (omitted at earliest page),
  defaults PageSize=20 to datarpc.

Validation:
- go build ./shortcuts/sheets/...                 PASS
- go test ./shortcuts/sheets/...                  PASS (sheets + backward)
- TestHistoryShortcuts_DryRun (5 cases incl. new --end-version case): PASS
- TestHistoryRevert_MissingRequiredFlag:           PASS
- TestFlagsFor_EveryRegisteredCommandHasDefs:      PASS
- TestFlagDefsGen_MatchesJSON:                     PASS
2026-06-30 14:42:42 +08:00
wenzhuozhen
41101e8dad chore(sheets): sync lark-sheets-history reference from spec (BE-2 transaction-id)
Mirror the upstream BE-2 fix in canonical-spec/references/lark_sheet_history/
cli-reference.md: +history-revert-status now uses --transaction-id (taken from
the async receipt returned by +history-revert), and +history-revert's
--history-version-id flips required→optional (Validate enforces requiredness
at runtime).

This file is the only history-only delta from the upstream sheet-skill-spec
sync; the rest of skills/lark-sheets/ stays on the cli's newer baseline
(/wiki/ URL support, +cells-set-image / +float-image-create, etc.) to match
commit 8ae516db's history-only mirror policy.

Spec source companion change: feat/sheet-history-revert in
ee/sheet-skill-spec, canonical-spec/{tool-shortcut-map.json,references/
lark_sheet_history/cli-reference.md}.
2026-06-30 14:42:42 +08:00
wenzhuozhen
66d4cf9b49 fix(sheets): align history flag-defs with inline shortcuts (green TestFlagsFor)
TestFlagsFor_EveryRegisteredCommandHasDefs was RED: generated flag-defs drifted
from the hand-written history shortcuts.
- +history-revert-status: flag-defs had --history-version-id; the BE-2 fix switched
  the shortcut to --transaction-id. Updated the entry to transaction-id.
- +history-revert / -status --history-version-id were marked required="required",
  but the inline flags are cobra-optional (requiredness enforced in Validate).
  Set required="optional" to match. Regenerated flag_defs_gen.go.

NOTE: canonical source is sheet-skill-spec (BE-3); apply the same change upstream
or the next sync:cli will regress this.
2026-06-30 14:42:41 +08:00
wenzhuozhen
d2c010bda6 fix(sheets): +history-revert-status keys on --transaction-id, not version id
BE-2 gap surfaced by PPE E2E: +history-revert-status sent history_version_id,
but the facade-agg history_revert_status tool keys on transaction_id (the async
receipt returned by +history-revert), so it returned "[40400] transaction_id is
required". Give the status shortcut its own --transaction-id flag + input
(excel_id + transaction_id); revert keeps --history-version-id. Tests updated.
2026-06-30 14:42:41 +08:00
wenzhuozhen
1eb300d6ab chore(sheets): sync lark_sheet_history skill + flag defs from sheet-skill-spec (BE-3)
Synced artifacts for the history shortcuts from ee/sheet-skill-spec (SSOT),
landed surgically (history-only) to avoid regressing this branch's newer
skills/lark-sheets content:
- skills/lark-sheets/references/lark-sheets-history.md (new, mirrored).
- skills/lark-sheets/SKILL.md: + Lark Sheet History references-table row only.
- shortcuts/sheets/data/flag-defs.json: + 3 history shortcuts (additive; no existing entries touched).
- shortcuts/sheets/flag_defs_gen.go: regenerated via go generate ./shortcuts/sheets/...
  (this also resolves the pre-existing flag-defs/gen drift — TestFlagDefsGen_MatchesJSON now passes).

NOT a full mirror: the rest of skills/lark-sheets/ + flag-schemas.json on this
branch (feat/lark-sheets-develop) are NEWER than the sheet-skill-spec worktree's
canonical (e.g. /wiki/ URL support, schema_version 3). A wholesale sync:cli would
have reverted them, so only the history delta is taken here. Full re-sync should
happen once sheet-skill-spec canonical is realigned with this branch.

Validation: go generate clean; go test ./shortcuts/sheets/
(TestFlagDefsGen_MatchesJSON, TestHistory*) PASS.

Spec source: active@2acd94a24ac3f835357a274a02344f78435bcc1c39ad0d695ce587f0cbddfb21
2026-06-30 14:42:41 +08:00
wenzhuozhen
69ebac97c7 feat(sheets): add +history-list / +history-revert / +history-revert-status shortcuts
BE-1 + BE-2 (larksuite/cli lark-sheets) for spec sheet-history-revert.
Three thin callTool wrappers over facade-agg history tools, following the
existing sheets Validate/DryRun/Execute + --url/--spreadsheet-token(/--token)
locator convention:
- +history-list (read, history_list): passes the tool output through verbatim;
  facade-agg already does the minor_histories/4-field/RFC3339 transform.
- +history-revert (write, history_revert): --history-version-id required,
  enforced at Validate stage with a typed *errs.ValidationError (no request on
  missing); returns the async receipt.
- +history-revert-status (read, history_revert_status): polls in-progress /
  success / failure.

Flags declared inline (not via *_gen.go) — flag_defs_gen.go / data/flag-defs.json
are synced from sheet-skill-spec (BE-3) and must not be hand-edited.

Notes:
- history_revert / history_revert_status depend on facade-agg's downstream RPC
  wiring, a DEFERRED follow-up; the tools return a "not wired yet" guard today.
  These CLI wrappers are correct and go live when the backend follow-up lands.
  +history-list is fully functional now.
- TestFlagDefsGen_MatchesJSON fails on baseline (pre-existing BE-3 gen/json
  drift); resolves once BE-3 sync:cli regenerates flag defs for these shortcuts.

Validation: go build ./shortcuts/sheets/... PASS; new tests
(TestHistoryShortcuts_DryRun, TestHistoryRevert_MissingVersionID) PASS.

Spec source: active@2acd94a24ac3f835357a274a02344f78435bcc1c39ad0d695ce587f0cbddfb21
2026-06-30 14:42:40 +08:00
anunwu-byted
5323e8e444 Merge pull request #1638 from larksuite/feat/pivot-info
feat(pivot): lark-sheets pivot reference 补 +pivot-list info 说明与落点覆盖校验
2026-06-29 17:35:13 +08:00
wenzhuozhen
4ace5ca4da Merge pull request #1626 from larksuite/feat/sheets-formula-verify
feat(sheets): add +formula-verify shortcut for verify_formula tool
2026-06-29 17:30:00 +08:00
wenzhuozhen
3e3f1bbf3b feat(sheets): add +formula-verify shortcut for verify_formula tool
Wraps the new verify_formula read tool in a CLI shortcut so AI agents
can run write-then-zero-error verification end-to-end:

  lark-cli sheets +formula-verify --url <url>

Scans formulas + cell error states across one or more sub-sheets and
returns a JSON status report (success / errors_found / partial).
Aggregates all 7 Excel error categories (#REF! / #DIV/0! / #VALUE! /
#NAME? / #NULL! / #NUM! / #N/A) plus compile failures into one
envelope; the tool always reports every error in the scan window —
callers needing a subset filter the returned error_summary
client-side. The internal scan cap is hidden from callers; when it
trips the response sets has_more=true and includes a warning_message
asking the caller to narrow --range / split --sheet-id and continue.

Flags follow the lark-sheets convention:
- --url / --spreadsheet-token (XOR public)
- --sheet-id / --sheet-name (repeat or comma-separate; mutually
  exclusive)
- --range (repeatable A1)
- --max-locations (default 20)
- --exit-on-error (CI gate: status='errors_found' → exit 2 with
  failed_precondition)

Generated artifacts (skills/lark-sheets/{SKILL.md, references/
lark-sheets-formula-verify.md}, shortcuts/sheets/data/flag-defs.json,
shortcuts/sheets/flag_defs_gen.go) are mirrored from sheet-skill-spec
generated/ via 'npm run sync:cli'. shortcuts.go registers
FormulaVerify alongside the other lark_sheet_formula_verify skill
shortcuts so +formula-verify is discoverable from
'lark-cli sheets --help'.

Tests cover the dry-run wire shape (excel_id + sheet_ids/sheet_names/
ranges/max_locations packing), the read scope (invoke_read URL), the
mutually-exclusive selector validation, the non-positive
--max-locations guard, and the --exit-on-error status matrix
(success/partial/errors_found/unknown).
2026-06-29 16:43:55 +08:00
wuyanchun.anunwu
9d15b70179 feat(pivot): lark-sheets pivot reference 补 +pivot-list info 说明与落点覆盖校验
+pivot-list 返回 info(page_range/content_range/error_state 等):
1) 判断目标单元格在透视表内(改配置 +pivot-update)还是区域外(改值 +cells-set);
2) 透视表展开后会覆盖已有数据,落点强烈优先默认自动新建子表;
3) 创建后用 info.error_state / content_range 校验有没有覆盖/冲突。
2026-06-29 14:13:21 +08:00
zhengzhijiej-tech
a179900d53 perf(sheets): cap fan-out cell-matrix materialization to prevent OOM (#1578)
* perf(sheets): cap fan-out cell-matrix materialization to prevent OOM

The +cells-set-style / +dropdown-set / +cells-batch-set-style /
+dropdown-update shortcuts expand a single A1 range into a rows×cols
matrix of per-cell maps client-side (the backing set_cell_range tool
takes an explicit cells matrix). rangeDimensions() had no upper bound,
so a tiny input like "A1:Z100000" balloons into ~2.6M heap maps (~900MB,
doubled again by json.Marshal) and can OOM the process before the
request is even sent.

Add a 50000-cell safety cap (checkStampMatrixBudget) gating every
fan-out materialization point, matching the documented but never-wired
--max-cells default. Oversized ranges now fail fast with a clear
validation error instead of allocating. Also preallocate the per-op
slices now that the range count is known up front.

Adds benchmarks + a boundary test as regression guards.

* perf(sheets): cap table-put/batch fan-out materialization (siblings of the cell-matrix cap)

The single-range fan-out cap (maxStampMatrixCells) left three sibling
ingress paths uncapped, each able to materialize an unbounded matrix or
op set in memory before the request leaves:

- +table-put / +workbook-create --sheets/--values: buildSheetMatrix
  builds the whole rows×cols matrix before slicing it into per-write
  batches; tablePutMaxCellsPerWrite only bounds the batch size, not the
  total input. Add tablePayload.checkCellBudget (1M-cell guardrail),
  enforced in validate() and in buildValuesPayload (the --values path
  bypasses validate()).

- batch fan-out (+cells-batch-set-style / +dropdown-update): per-range
  checkStampMatrixBudget can't stop many ranges from summing past the
  cap. Add an aggregate cell budget (checkBatchStampBudget) and a shared
  maxBatchRanges (100) count cap in validateDropdownRanges — covering
  all fan-out commands and replacing the now-redundant +dropdown-delete
  count check.

- +batch-update: cap --operations at maxBatchOperations (100) in
  translateBatchOperations.

Adds boundary regression tests for each cap. go vet + gofmt clean; full
shortcuts/sheets + backward suites green.

* test(sheets): measure table-put matrix materialization cost

Add BenchmarkBuildSheetMatrix_* and TestTablePutMatrixPeakMemory mirroring
the fan-out probes. Confirms the +table-put/+workbook-create ingress has the
same OOM profile as the single-range stamp: 2.6M cells → ~917 MB / 5.3M allocs
(+875 MB resident heap) materialized before the first write — now rejected up
front by checkCellBudget.
2026-06-29 14:03:12 +08:00
zhengzhijiej-tech
646304a1c7 feat(sheets): add --type bitable to +sheet-create for creating bitable sub-sheets (#1520) 2026-06-29 11:42:01 +08:00
xiongyuanwen-byted
1870348fc9 docs(sheets): inline editing rules into SKILL.md and clarify flag descriptions
- Move cross-cutting editing rules and execution notes into the root
  SKILL.md and drop the now-redundant core-operations reference
- Clarify flag descriptions: offset must be explicit inside +batch-update,
  range prefixes written bare (no quotes), chart requires a dim index,
  untyped --values lose date/number types, ungroup level semantics
- Sync the corresponding reference docs
2026-06-29 10:35:10 +08:00
xiongyuanwen-byted
d46e3ccad2 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-27 22:43:31 +08:00
zhengzhijiej-tech
e3e5944c86 feat(sheets): support font_family in cell styles (#1549)
Add a font_family field to cell_styles so a cell's font name can be set
and read back through every style entry point:

- +cells-set (--cells JSON) and +cells-set-style / +cells-batch-set-style
  gain a font_family field / --font-family flat flag
- +workbook-create / +table-put --styles accept font_family in cell_styles
- +cells-get returns font_family

helpers.go buildCellStyleFromFlags reads the --font-family flag;
lark_sheet_workbook.go allows font_family in the --styles cell_styles
whitelist; data/ + skills/ are synced from sheet-skill-spec.
2026-06-25 11:33:53 +08:00
146 changed files with 6954 additions and 4055 deletions

3
.gitignore vendored
View File

@@ -27,9 +27,6 @@ Thumbs.db
# Go
docs/ref
docs/
!tests/cli_e2e/docs/
!tests/cli_e2e/docs/*.go
!tests/cli_e2e/docs/*.md
vendor/

View File

@@ -2,22 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.65] - 2026-07-03
### Features
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
### Bug Fixes
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
### Documentation
- **drive**: Document 30-char query limit for `+search` (#1560)
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
- **doc**: Sync lark-doc skill content from online-doc (#1701)
## [v1.0.64] - 2026-07-02
### Features
@@ -1371,7 +1355,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61

View File

@@ -233,24 +233,6 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # Comma-separated values
```
### JSON Output Contract
With `--format json` (the default), success and error envelopes are distinct.
Success goes to **stdout**, exit code `0`:
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
Errors go to **stderr**, non-zero exit code:
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
### Pagination
```bash

View File

@@ -234,24 +234,6 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # 逗号分隔值
```
### JSON 输出契约
`--format json`(默认)下,成功与错误的信封结构不同。
成功信封写入 **stdout**,退出码 0
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
错误信封写入 **stderr**,退出码非 0
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code``msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
### 分页
```bash

View File

@@ -20,28 +20,13 @@ import (
"github.com/spf13/cobra"
)
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
cmd := NewCmdApi(f, runF)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return cmd
}
func newTestRootCmd() *cobra.Command {
return &cobra.Command{
Use: "lark-cli",
SilenceErrors: true,
SilenceUsage: true,
}
}
func TestApiCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -69,7 +54,7 @@ func TestApiCmd_DryRun(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute()
if err != nil {
@@ -92,7 +77,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with --page-size should not error, got: %v", err)
@@ -113,7 +98,7 @@ func TestApiCmd_BotMode(t *testing.T) {
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -140,7 +125,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET"}) // missing path
err := cmd.Execute()
if err == nil {
@@ -153,7 +138,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
err := cmd.Execute()
if err == nil {
@@ -166,7 +151,7 @@ func TestApiValidArgsFunction(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
fn := cmd.ValidArgsFunction
tests := []struct {
@@ -232,7 +217,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
@@ -251,7 +236,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -270,7 +255,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
err := cmd.Execute()
if err == nil {
@@ -287,7 +272,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return apiRun(opts)
})
@@ -312,7 +297,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
ContentType: "application/octet-stream",
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -343,7 +328,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err != nil {
@@ -383,7 +368,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
err := cmd.Execute()
// Should return an error
@@ -424,7 +409,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err != nil {
@@ -463,7 +448,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
@@ -498,7 +483,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -564,8 +549,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
},
})
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -615,8 +600,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
},
})
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -671,8 +656,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
},
})
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
@@ -736,7 +721,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -756,7 +741,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -775,7 +760,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
@@ -806,7 +791,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
err := cmd.Execute()
if err != nil {
@@ -827,7 +812,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
@@ -845,7 +830,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
@@ -874,7 +859,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute()
if err != nil {
@@ -895,7 +880,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -914,7 +899,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -932,7 +917,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
@@ -949,7 +934,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
@@ -966,7 +951,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
@@ -989,7 +974,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -1030,7 +1015,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
err := cmd.Execute()
if err == nil {
@@ -1056,7 +1041,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})

View File

@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
return cmd
}
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
// It uses the same source as schema execution so completion candidates match
// what `schema` can resolve.
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
// It uses the embedded source so completion candidates match what `schema`
// execution can resolve (both overlay-free).
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
mode := f.ResolveStrictMode(cmd.Context())
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
directive := cobra.ShellCompDirectiveNoFileComp
if noSpace {
directive |= cobra.ShellCompDirectiveNoSpace
@@ -86,19 +86,13 @@ func schemaRun(opts *SchemaOptions) error {
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
}
// runSchema resolves the path through the schema catalog and renders the
// runSchema resolves the path through the embedded catalog and renders the
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
// output shape — a single resolved method renders as one envelope object,
// anything broader as an array — and maps resolve failures to hints.
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
catalog := registry.SchemaCatalog()
if len(catalog.Services()) == 0 {
// No embedded metadata and the runtime fallback is empty too: offline
// with a cold cache, remote meta off, or an unwritable cache dir.
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
}
catalog := registry.EmbeddedCatalog()
target, err := catalog.Resolve(parts)
if err != nil {
return resolveError(err)

View File

@@ -102,8 +102,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
Long: `Update lark-cli to the latest version.
Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version>
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
- npm install: runs npm install -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts).
@@ -165,7 +164,7 @@ func updateRun(opts *UpdateOptions) error {
if !detect.CanAutoUpdate() {
return doManualUpdate(opts, io, cur, latest, detect, updater)
}
return doAutoUpdate(opts, io, cur, latest, detect, updater)
return doNpmUpdate(opts, io, cur, latest, updater)
}
// --- Output helpers ---
@@ -227,23 +226,12 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
if detect.Method == selfupdate.InstallPnpm {
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
} else {
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
}
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
emitSkillsTextHints(io, skillsResult)
return nil
}
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
pm := "npm"
install := updater.RunNpmInstall
if detect.Method == selfupdate.InstallPnpm {
pm = "pnpm"
install = updater.RunPnpmInstall
}
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
restore, err := updater.PrepareSelfReplace()
if err != nil {
return reportError(opts, io, "update_error",
@@ -251,19 +239,19 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
}
if !opts.JSON {
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
}
npmResult := install(latest)
npmResult := updater.RunNpmInstall(latest)
if npmResult.Err != nil {
restore()
combined := npmResult.CombinedOutput()
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
"detail": selfupdate.Truncate(combined, maxNpmOutput),
"hint": permissionHint(combined, pm),
"hint": permissionHint(combined),
},
})
return output.ErrBare(output.ExitAPI)
@@ -275,7 +263,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
}
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
if hint := permissionHint(combined, pm); hint != "" {
if hint := permissionHint(combined); hint != "" {
fmt.Fprintf(io.ErrOut, " %s\n", hint)
}
return output.ErrBare(output.ExitAPI)
@@ -286,7 +274,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
if err := updater.VerifyBinary(latest); err != nil {
restore()
msg := fmt.Sprintf("new binary verification failed: %s", err)
hint := verificationFailureHint(updater, latest, pm)
hint := verificationFailureHint(updater, latest)
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false,
@@ -316,33 +304,23 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
if skillsResult != nil {
skillsPM := "npx"
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
skillsPM = "pnpm dlx"
}
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
}
emitSkillsTextHints(io, skillsResult)
return nil
}
func permissionHint(pmOutput, pm string) string {
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
return ""
func permissionHint(npmOutput string) string {
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
}
if pm == "pnpm" {
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
}
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
return ""
}
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
if updater.CanRestorePreviousVersion() {
return "the previous version has been restored"
}
if pm == "pnpm" {
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}

View File

@@ -57,27 +57,6 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
t.Cleanup(func() { newUpdater = origNew })
}
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
// and fails the test if the npm install path is invoked.
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.PnpmInstallOverride = pnpmFn
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
t.Errorf("npm install must not be called for a pnpm install")
return &selfupdate.NpmResult{}
}
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
return func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
@@ -102,110 +81,6 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
}
}
func TestUpdatePnpm_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected updated in output, got: %s", out)
}
}
func TestUpdatePnpm_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "via pnpm") {
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
}
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
}
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message, got: %s", out)
}
}
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
)
err := cmd.Execute()
if err == nil {
t.Fatal("expected error exit")
}
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
t.Errorf("expected failure envelope, got: %s", out)
}
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
}
}
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
t.Errorf("expected pnpm manual reason, got: %s", out)
}
if !strings.Contains(out, "pnpm add -g") {
t.Errorf("expected pnpm add -g hint, got: %s", out)
}
}
func TestNormalizeVersion(t *testing.T) {
tests := []struct {
input string
@@ -391,9 +266,6 @@ func TestUpdateNpm_Human(t *testing.T) {
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message in stderr, got: %s", out)
}
if !strings.Contains(out, "Updating skills via npx ...") {
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
}
}
func TestUpdateForce_JSON(t *testing.T) {
@@ -867,9 +739,9 @@ func TestPermissionHint(t *testing.T) {
origOS := currentOS
defer func() { currentOS = origOS }()
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
// Linux: EACCES should produce a hint with npm prefix guidance.
currentOS = "linux"
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
if !strings.Contains(hint, "npm global prefix") {
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
}
@@ -877,25 +749,16 @@ func TestPermissionHint(t *testing.T) {
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
}
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
if !strings.Contains(pnpmHint, "pnpm setup") {
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
}
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
}
// Windows: EACCES hint is suppressed (no EACCES on Windows).
currentOS = "windows"
hint = permissionHint("EACCES: permission denied", "npm")
hint = permissionHint("EACCES: permission denied")
if hint != "" {
t.Errorf("expected empty hint on Windows, got: %s", hint)
}
// Non-EACCES error: always empty.
currentOS = "linux"
if got := permissionHint("some other error", "npm"); got != "" {
if got := permissionHint("some other error"); got != "" {
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
}
}

View File

@@ -72,28 +72,6 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via
`CategoryPolicy`.
### Success envelope (stdout)
For contrast: success responses render to **stdout** as an
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
```json
{
"ok": true,
"identity": "user",
"data": { "guid": "e297d3d0-..." },
"meta": { "count": 1 }
}
```
Consumers must branch on `ok` (or the process exit code). The success
envelope has **no top-level `code` or `msg` field**`code` exists only
inside `error`, where it is the upstream numeric code (invariant 4).
Wrappers that follow the raw OpenAPI convention and test `code == 0`
will misclassify every successful call as a failure, which is
especially dangerous around write commands (e.g. retrying a create that
already succeeded).
## Categories
| Category | When | Exit | Typed struct |

View File

@@ -77,10 +77,14 @@ func loadService(service string) map[string]json.RawMessage {
// space→dot fallback covers domains where the two already coincide.
func commandFormResolver(service string) func(string) string {
byForm := map[string]string{}
if svc, ok := registry.SchemaCatalog().Service(service); ok {
for _, svc := range registry.EmbeddedServicesTyped() {
if svc.Name != service {
continue
}
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
}
break
}
return func(h string) string {
h = strings.TrimSpace(h)

View File

@@ -6,10 +6,12 @@ package cmdutil
import (
"context"
"net/http"
"os"
"reflect"
"runtime/debug"
"strings"
"sync"
"unicode"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
@@ -38,6 +40,8 @@ const (
BuildKindUnknown = "unknown"
officialModulePath = "github.com/larksuite/cli"
agentTraceMaxLen = 1024
)
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
@@ -45,6 +49,25 @@ func UserAgentValue() string {
return SourceValue + "/" + build.Version
}
// AgentTraceValue returns a header-safe value from the
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
// surrounding whitespace, rejects values containing any Unicode
// control character or exceeding agentTraceMaxLen, and returns ""
// for any invalid or empty value. Callers can use the result
// directly in HTTP headers without further sanitisation.
func AgentTraceValue() string {
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
if v == "" || len(v) > agentTraceMaxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}
// BaseSecurityHeaders returns headers that every request must carry.
func BaseSecurityHeaders() http.Header {
h := make(http.Header)
@@ -52,7 +75,7 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
if v := envvars.AgentTrace(); v != "" {
if v := AgentTraceValue(); v != "" {
h.Set(HeaderAgentTrace, v)
}
return h

View File

@@ -6,6 +6,7 @@ package cmdutil
import (
"context"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/extension/credential"
@@ -263,9 +264,88 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
}
// ---------------------------------------------------------------------------
// HeaderAgentTrace injection (via BaseSecurityHeaders)
// AgentTraceValue / HeaderAgentTrace
// ---------------------------------------------------------------------------
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
}
}
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
if got := AgentTraceValue(); got != "trace-abc-123" {
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
if got := AgentTraceValue(); got != "trace-trim" {
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " ")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTraceValue_RejectsLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
}
}
func TestAgentTraceValue_RejectsTab(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
}
}
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
}
}
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
}
}
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(envvars.CliAgentTrace, longVal)
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(envvars.CliAgentTrace, val)
if got := AgentTraceValue(); got != val {
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders()

View File

@@ -19,7 +19,6 @@ const (
// Content safety scanning mode
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"

View File

@@ -1,36 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package envvars
import (
"os"
"strings"
"unicode"
)
const (
agentNameMaxLen = 128
agentTraceMaxLen = 1024
)
func AgentName() string {
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
}
func AgentTrace() string {
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
}
func sanitizeSingleLine(raw string, maxLen int) string {
v := strings.TrimSpace(raw)
if v == "" || len(v) > maxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}

View File

@@ -1,131 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package envvars
import (
"strings"
"testing"
)
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(CliAgentName, "")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty when env unset", got)
}
}
func TestAgentName_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentName, "claude-code")
if got := AgentName(); got != "claude-code" {
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
}
}
func TestAgentName_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentName, " cursor ")
if got := AgentName(); got != "cursor" {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
}
}
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
}
}
func TestAgentName_RejectsControlChar(t *testing.T) {
t.Setenv(CliAgentName, "agent\x01injected")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty for control char value", got)
}
}
func TestAgentName_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentNameMaxLen+1)
t.Setenv(CliAgentName, longVal)
if got := AgentName(); got != "" {
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
}
}
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(CliAgentTrace, "")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
}
}
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentTrace, "trace-abc-123")
if got := AgentTrace(); got != "trace-abc-123" {
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentTrace, " trace-trim ")
if got := AgentTrace(); got != "trace-trim" {
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(CliAgentTrace, " ")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTrace_RejectsCRLF(t *testing.T) {
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTrace_RejectsLF(t *testing.T) {
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
}
}
func TestAgentTrace_RejectsTab(t *testing.T) {
t.Setenv(CliAgentTrace, "val\tinjected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
}
}
func TestAgentTrace_RejectsControlChar(t *testing.T) {
t.Setenv(CliAgentTrace, "val\x01injected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
}
}
func TestAgentTrace_RejectsDEL(t *testing.T) {
t.Setenv(CliAgentTrace, "val\x7finjected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
}
}
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(CliAgentTrace, longVal)
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(CliAgentTrace, val)
if got := AgentTrace(); got != val {
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}

View File

@@ -6,7 +6,8 @@ package registry
import "github.com/larksuite/cli/internal/apicatalog"
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
// metadata — deterministic across machines, for golden tests and schema lint.
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
// and schema lint.
func EmbeddedCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
}
@@ -17,14 +18,3 @@ func EmbeddedCatalog() apicatalog.Catalog {
func RuntimeCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
}
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
// otherwise the merged runtime catalog. Binaries built from the bare Go module
// embed only the empty meta_data_default.json stub, so the embedded view has
// nothing to resolve; the merged view is the only data such binaries have.
func SchemaCatalog() apicatalog.Catalog {
if len(EmbeddedServicesTyped()) > 0 {
return EmbeddedCatalog()
}
return RuntimeCatalog()
}

View File

@@ -1,67 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
)
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
// restores them (with a full state reset) on cleanup.
func swapEmbeddedMeta(t *testing.T, data []byte) {
t.Helper()
resetInit()
orig := embeddedMetaJSON
embeddedMetaJSON = data
t.Cleanup(func() {
waitBackgroundRefresh()
embeddedMetaJSON = orig
resetInit()
})
}
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
c := SchemaCatalog()
if c.Source() != apicatalog.SourceEmbedded {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
}
if _, ok := c.Service("embedded_svc"); !ok {
t.Fatal("expected embedded_svc from embedded metadata")
}
}
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
// from the bare Go module (plugin builds): only the empty meta_data_default.json
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
// Init seeds via sync fetch.
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write(testEnvelopeJSON("remote_svc"))
}))
defer ts.Close()
testMetaURL = ts.URL
c := SchemaCatalog()
if c.Source() != apicatalog.SourceRuntime {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
}
if _, ok := c.Service("remote_svc"); !ok {
t.Fatal("expected remote_svc from runtime fallback")
}
}

View File

@@ -15,7 +15,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/update"
)
//go:embed scope_priorities.json scope_overrides.json
@@ -86,9 +85,7 @@ func InitWithBrand(brand core.LarkBrand) {
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
if !brandChanged {
// After a CLI upgrade the embedded data can be fresher than an old
// cache; an equal/older cache must not shadow it.
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
if cached, err := loadCachedMerged(); err == nil {
overlayMergedServices(cached)
}
}

View File

@@ -1,102 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
)
// seedCache writes a cache file + cache meta for one service whose Title is
// marker, tagged with the given top-level data version and brand.
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
t.Helper()
cDir := filepath.Join(dir, "cache")
if err := os.MkdirAll(cDir, 0700); err != nil {
t.Fatal(err)
}
reg := MergedRegistry{
Version: version,
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
}
data, _ := json.Marshal(reg)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
t.Fatal(err)
}
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
mData, _ := json.Marshal(cm)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
t.Fatal(err)
}
}
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
t.Helper()
embedded, _ := json.Marshal(MergedRegistry{
Version: embeddedVer,
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
})
swapEmbeddedMeta(t, embedded)
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
InitWithBrand(core.BrandFeishu)
}
func titleOf(t *testing.T, name string) string {
t.Helper()
svc, ok := ServiceTyped(name)
if !ok {
t.Fatalf("service %q not loaded", name)
}
return svc.Title
}
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
}
}
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
initWithCache(t, "2.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("older cache: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
initWithCache(t, "1.0.0", "2.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("newer cache: got %q, want CACHE", got)
}
}
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "not-a-semver")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
// plugin builds without compiled meta_data.json still get remote data.
initWithCache(t, "0.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
}
}

View File

@@ -72,11 +72,9 @@ func hasEmbeddedServices() bool {
}
// testRegistry returns a minimal MergedRegistry with one service.
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
// so cache overlay passes the version gate in InitWithBrand.
func testRegistry(name string) MergedRegistry {
return MergedRegistry{
Version: "1.0.0",
Version: "test-1.0",
Services: []meta.Service{
{
Name: name,
@@ -162,7 +160,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
}
func TestCacheHit_WithinTTL(t *testing.T) {
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
resetInit()
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -199,7 +197,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
}
func TestNetworkError_SilentDegradation(t *testing.T) {
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
resetInit()
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -373,8 +371,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
if data == nil {
t.Fatal("expected non-nil data")
}
if reg.Version != "1.0.0" {
t.Errorf("expected version 1.0.0, got %s", reg.Version)
if reg.Version != "test-1.0" {
t.Errorf("expected version test-1.0, got %s", reg.Version)
}
}

View File

@@ -32,7 +32,6 @@ type InstallMethod int
const (
InstallNpm InstallMethod = iota
InstallPnpm
InstallManual
)
@@ -54,32 +53,22 @@ var (
// DetectResult holds installation detection results.
type DetectResult struct {
Method InstallMethod
ResolvedPath string
NpmAvailable bool
PnpmAvailable bool
Method InstallMethod
ResolvedPath string
NpmAvailable bool
}
// CanAutoUpdate returns true if the CLI can update itself automatically.
func (d DetectResult) CanAutoUpdate() bool {
switch d.Method {
case InstallNpm:
return d.NpmAvailable
case InstallPnpm:
return d.PnpmAvailable
}
return false
return d.Method == InstallNpm && d.NpmAvailable
}
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
func (d DetectResult) ManualReason() string {
switch {
case d.Method == InstallNpm && !d.NpmAvailable:
if d.Method == InstallNpm && !d.NpmAvailable {
return "installed via npm, but npm is not available in PATH"
case d.Method == InstallPnpm && !d.PnpmAvailable:
return "installed via pnpm, but pnpm is not available in PATH"
}
return "not installed via npm or pnpm"
return "not installed via npm"
}
// NpmResult holds the result of an npm install or skills update execution.
@@ -103,7 +92,6 @@ func (r *NpmResult) CombinedOutput() string {
type Updater struct {
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
PnpmInstallOverride func(version string) *NpmResult
SkillsIndexFetchOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error
@@ -113,38 +101,17 @@ type Updater struct {
// running binary is successfully renamed to .old. Used by
// CanRestorePreviousVersion to report whether rollback is possible.
backupCreated bool
// detectCache memoizes the first real DetectInstallMethod result. How this
// binary was installed cannot change during a single process, so caching is
// the correct semantics — and it is required for correctness: the update
// flow mutates the install (pnpm add -g / npm install -g) before syncing
// skills, so a re-detection at skills time could resolve a now-stale
// os.Executable path and misclassify. Seeded pre-update by the first call
// (updateRun), it keeps the post-update skills launcher consistent with the
// launcher reported to the user. Not goroutine-safe; the update flow is
// sequential.
detectCache *DetectResult
}
// New creates an Updater with default (real) behavior.
func New() *Updater { return &Updater{} }
// DetectInstallMethod determines how the CLI was installed and whether the
// owning package manager is available for auto-update.
// DetectInstallMethod determines how the CLI was installed and whether
// npm is available for auto-update.
func (u *Updater) DetectInstallMethod() DetectResult {
if u.DetectOverride != nil {
return u.DetectOverride()
}
if u.detectCache != nil {
return *u.detectCache
}
result := u.detectInstallMethod()
u.detectCache = &result
return result
}
// detectInstallMethod performs the real (uncached) detection.
func (u *Updater) detectInstallMethod() DetectResult {
exe, err := vfs.Executable()
if err != nil {
return DetectResult{Method: InstallManual}
@@ -153,54 +120,24 @@ func (u *Updater) detectInstallMethod() DetectResult {
if err != nil {
return DetectResult{Method: InstallManual, ResolvedPath: exe}
}
_, npmErr := exec.LookPath("npm")
_, pnpmErr := exec.LookPath("pnpm")
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
}
// detectFromResolved classifies the resolved binary path into an install
// method and records package-manager availability. Split out from
// DetectInstallMethod so the classification is unit-testable without touching
// the filesystem or PATH.
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
method := InstallManual
if strings.Contains(resolved, "node_modules") {
if containsPnpmMarker(resolved) {
method = InstallPnpm
} else {
method = InstallNpm
}
method = InstallNpm
}
d := DetectResult{Method: method, ResolvedPath: resolved}
switch method {
case InstallNpm:
d.NpmAvailable = npmOnPath
case InstallPnpm:
d.PnpmAvailable = pnpmOnPath
}
return d
}
// containsPnpmMarker reports whether the resolved binary path belongs to a
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
// (a ".pnpm" directory segment) and the global content-addressable store,
// whose resolved path runs through pnpm's home directory (e.g.
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
// followed by "store". Matching only these two shapes (rather than any bare
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
// a directory named "pnpm". Windows separators are normalized to "/" so the
// classification is OS-independent and unit-testable anywhere.
func containsPnpmMarker(p string) bool {
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
for i, part := range parts {
if part == ".pnpm" {
return true
}
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
return true
npmAvailable := false
if method == InstallNpm {
if _, err := exec.LookPath("npm"); err == nil {
npmAvailable = true
}
}
return false
return DetectResult{
Method: method,
ResolvedPath: resolved,
NpmAvailable: npmAvailable,
}
}
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
@@ -226,29 +163,6 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r
}
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
if u.PnpmInstallOverride != nil {
return u.PnpmInstallOverride(version)
}
r := &NpmResult{}
pnpmPath, err := exec.LookPath("pnpm")
if err != nil {
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
return r
}
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
}
return r
}
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
if u.SkillsIndexFetchOverride != nil {
return u.SkillsIndexFetchOverride()
@@ -347,40 +261,19 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
return u.runSkillsCommand(args...)
}
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
// pnpm-only environments (pnpm's standalone installer bundles Node without
// putting npm/npx on PATH) can still sync skills after a self-update.
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
// launcher selection is unit-testable on any platform.
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
if method == InstallPnpm && pnpmAvailable {
r := args
if len(r) > 0 && r[0] == "-y" {
r = r[1:]
}
return "pnpm", append([]string{"dlx"}, r...)
}
return "npx", args
}
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
if u.SkillsCommandOverride != nil {
return u.SkillsCommandOverride(args...)
}
r := &NpmResult{}
det := u.DetectInstallMethod()
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
binPath, err := exec.LookPath(launcher)
npxPath, err := exec.LookPath("npx")
if err != nil {
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
return r
}
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
cmd := exec.CommandContext(ctx, npxPath, args...)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()

View File

@@ -371,147 +371,3 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
}
}
func TestContainsPnpmMarker(t *testing.T) {
cases := []struct {
path string
want bool
}{
// Classic virtual-store layout (.pnpm segment).
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
// Global content-addressable store layout (pnpm 11): resolved path runs
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
// npm and non-package installs — no pnpm/.pnpm segment.
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
{"/usr/local/bin/lark-cli", false},
// Substrings that must NOT match: segment must be exactly .pnpm, or
// "pnpm" immediately followed by "store".
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
// living under a dir named pnpm) must not be misclassified as pnpm.
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
}
for _, c := range cases {
if got := containsPnpmMarker(c.path); got != c.want {
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
}
}
}
func TestDetectInstallMethod_Pnpm(t *testing.T) {
u := &Updater{DetectOverride: nil}
u.DetectOverride = func() DetectResult {
// Exercise the real classification by feeding a resolved path via a small shim.
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
}
got := u.DetectInstallMethod()
if got.Method != InstallPnpm {
t.Errorf("Method = %v, want InstallPnpm", got.Method)
}
if !got.PnpmAvailable {
t.Errorf("PnpmAvailable = false, want true")
}
}
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
t.Errorf("npm path Method = %v, want InstallNpm", m)
}
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
t.Errorf("manual path Method = %v, want InstallManual", m)
}
}
func TestCanAutoUpdate_Pnpm(t *testing.T) {
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
t.Error("pnpm available should CanAutoUpdate")
}
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
t.Error("pnpm unavailable should not CanAutoUpdate")
}
}
func TestManualReason_Pnpm(t *testing.T) {
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
t.Errorf("pnpm reason = %q", got)
}
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
t.Errorf("manual reason = %q", got)
}
}
func TestRunPnpmInstall_Override(t *testing.T) {
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
r := &NpmResult{}
r.Stdout.WriteString("added @larksuite/cli@" + version)
return r
}}
got := u.RunPnpmInstall("2.0.0")
if got.Err != nil {
t.Fatalf("unexpected err: %v", got.Err)
}
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
}
}
func TestRunPnpmInstall_Error(t *testing.T) {
wantErr := errors.New("boom")
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
t.Errorf("err = %v, want %v", got.Err, wantErr)
}
}
func TestSkillsInvocation(t *testing.T) {
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
cases := []struct {
name string
method InstallMethod
pnpmAvailable bool
args []string
wantLauncher string
wantRest []string
}{
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
"npx", addArgs},
{"npm install → npx unchanged", InstallNpm, false, addArgs,
"npx", addArgs},
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
"npx", []string{"-y", "skills", "ls", "-g"}},
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
if gotLauncher != c.wantLauncher {
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
}
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
}
})
}
}
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
// hazard: DetectInstallMethod must return the first (pre-update) detection on
// subsequent calls, so the skills launcher chosen after the binary is replaced
// stays consistent with what was detected — and reported — before the update.
func TestDetectInstallMethod_Caches(t *testing.T) {
u := New()
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
u.detectCache = &cached
got := u.DetectInstallMethod()
if got.Method != InstallPnpm || !got.PnpmAvailable {
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
}
}

View File

@@ -65,7 +65,7 @@ func safePath(raw, flagName string) (string, error) {
}
if isAbsolutePath(raw) {
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: use a relative path like ./filename; flags that support stdin can read an out-of-tree file via '-' instead)", flagName, raw)
}
path := filepath.Clean(raw)

View File

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

View File

@@ -67,26 +67,6 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
return attendees, nil
}
func attendeesIncludeRoom(attendees []map[string]string) bool {
for _, attendee := range attendees {
if attendee["type"] == "resource" || attendee["room_id"] != "" {
return true
}
}
return false
}
func guideApprovalRoomReasonError(err error, attendees []map[string]string) error {
if err == nil || !attendeesIncludeRoom(attendees) {
return err
}
p, ok := errs.ProblemOf(err)
if !ok || !strings.Contains(strings.ToLower(p.Hint), "approval_reason") {
return err
}
return withStepContext(err, "approval meeting rooms require attendees[].approval_reason; calendar +create does not expose this low-frequency field. Create the event with the raw API flow, then use `lark-cli calendar event.attendees create --as user` with attendees[].approval_reason for the room attendee.")
}
var CalendarCreate = common.Shortcut{
Service: "calendar",
Command: "+create",
@@ -245,7 +225,6 @@ var CalendarCreate = common.Shortcut{
"need_notification": true,
})
if err != nil {
err = guideApprovalRoomReasonError(err, attendees)
// Rollback: delete the event
_, rollbackErr := runtime.CallAPITyped("DELETE",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),

View File

@@ -673,76 +673,6 @@ func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) {
}
}
func TestCreate_ApprovalRoomMissingReason_GuidesRawAttendeesAPI(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_approval_room",
"summary": "Approval Room",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/events/evt_approval_room/attendees",
Body: map[string]interface{}{
"code": codeInvalidParamsWithDetail,
"msg": "invalid params",
"error": map[string]interface{}{
"details": []interface{}{
map[string]interface{}{"value": "attendees[0].approval_reason is required for approval meeting rooms"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/events/evt_approval_room",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Approval Room",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--attendee-ids", "omm_room1",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error for approval room missing approval_reason, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf returned !ok for %T", err)
}
if p.Category != errs.CategoryAPI {
t.Errorf("category=%q, want %q", p.Category, errs.CategoryAPI)
}
if p.Subtype != errs.SubtypeInvalidParameters {
t.Errorf("subtype=%q, want %q", p.Subtype, errs.SubtypeInvalidParameters)
}
if p.Code != codeInvalidParamsWithDetail {
t.Errorf("code=%d, want %d", p.Code, codeInvalidParamsWithDetail)
}
for _, want := range []string{"approval_reason", "calendar event.attendees create", "--as user", "rolled back successfully"} {
if !strings.Contains(p.Hint, want) {
t.Errorf("hint should contain %q, got: %q", want, p.Hint)
}
}
}
// When the add-attendees call fails AND the rollback DELETE also fails, the
// primary error stays the add failure (classification preserved) and the Hint
// must surface BOTH the rollback failure reason and the orphan event_id so the

View File

@@ -1068,7 +1068,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
if rctx.stdinConsumed {
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
WithParam("--"+fl.Name).
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --%s @/path/to/file)", fl.Name)
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others inline or as @file with a relative path under the current directory (e.g. --%s @./payload.json)", fl.Name)
}
rctx.stdinConsumed = true
data, err := io.ReadAll(rctx.IO().In)
@@ -1102,9 +1102,16 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
}
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil {
return ValidationErrorf("--%s: %v", fl.Name, err).
verr := ValidationErrorf("--%s: %v", fl.Name, err).
WithParam("--" + fl.Name).
WithCause(err)
if slices.Contains(fl.Input, Stdin) {
// Rejected @file paths are usually absolute (temp files under
// /tmp). Steer toward stdin rather than cd / copying the file
// into the project tree.
verr = verr.WithHint("this flag also reads stdin: --%s - < %s", fl.Name, path)
}
return verr
}
// strip a leading UTF-8 BOM so it
// can't corrupt the first CSV cell or break JSON parsing downstream.

View File

@@ -227,6 +227,35 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
}
}
// TestResolveInputFlags_FileErrorSuggestsStdin pins the recovery hint when
// an @file path is rejected (typically an absolute /tmp path): flags that
// also accept stdin must point at `--flag - < path` — never at cd'ing into
// the target directory or copying the file into the project tree.
func TestResolveInputFlags_FileErrorSuggestsStdin(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"csv": "@/tmp/does-not-exist.csv"}, "")
flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}}
err := resolveInputFlags(rctx, flags)
if err == nil {
t.Fatal("expected error for rejected @file path")
}
vErr := assertValidationParam(t, err, "--csv")
if !strings.Contains(vErr.Hint, "--csv - < /tmp/does-not-exist.csv") {
t.Errorf("hint %q should show the stdin form of the same call", vErr.Hint)
}
// A flag without stdin support must not get the stdin hint.
rctx = newTestRuntimeWithStdin(map[string]string{"file": "@/tmp/does-not-exist.xlsx"}, "")
err = resolveInputFlags(rctx, []Flag{{Name: "file", Input: []string{File}}})
if err == nil {
t.Fatal("expected error for rejected @file path")
}
vErr = assertValidationParam(t, err, "--file")
if strings.Contains(vErr.Hint, "stdin") {
t.Errorf("hint %q must not suggest stdin for a file-only flag", vErr.Hint)
}
}
func TestStripUTF8BOM(t *testing.T) {
cases := []struct{ name, in, want string }{
{"leading BOM removed", "\uFEFFhello", "hello"},

View File

@@ -1,261 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type docsHistoryListSpec struct {
Doc documentRef
PageSize int
PageToken string
}
type docsHistoryRevertSpec struct {
Doc documentRef
HistoryVersionID string
WaitTimeoutMs int
}
type docsHistoryRevertStatusSpec struct {
Doc documentRef
TaskID string
}
func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) {
ref, err := parseDocumentRef(raw)
if err != nil {
return documentRef{}, err
}
if ref.Kind == "doc" {
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc")
}
return ref, nil
}
func validateDocsHistoryPageSize(pageSize int) error {
if pageSize < 1 || pageSize > 20 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
}
return nil
}
func validateDocsHistoryVersionID(historyVersionID string) error {
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id").WithCause(err)
}
if version <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id")
}
return nil
}
func validateDocsHistoryWaitTimeout(timeoutMs int) error {
if timeoutMs < 0 || timeoutMs > 30000 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
}
return nil
}
func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} {
params := map[string]interface{}{
"page_size": spec.PageSize,
}
if spec.PageToken != "" {
params["page_token"] = spec.PageToken
}
return params
}
func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} {
return map[string]interface{}{
"history_version_id": spec.HistoryVersionID,
"wait_timeout_ms": spec.WaitTimeoutMs,
}
}
func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} {
return map[string]interface{}{
"task_id": spec.TaskID,
}
}
func docsHistoryAPIPath(docToken, suffix string) string {
return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix)
}
var DocsHistoryList = common.Shortcut{
Service: "docs",
Command: "+history-list",
Description: "List Lark document history versions",
Risk: "read",
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-list"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil {
return err
}
return validateDocsHistoryPageSize(runtime.Int("page-size"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
spec := docsHistoryListSpec{
Doc: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
}
return common.NewDryRunAPI().
Desc("OpenAPI: list document history versions").
GET("/open-apis/docs_ai/v1/documents/:document_id/histories").
Set("document_id", spec.Doc.Token).
Params(docsHistoryListParams(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
spec := docsHistoryListSpec{
Doc: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
}
data, err := runtime.CallAPITyped(
http.MethodGet,
docsHistoryAPIPath(spec.Doc.Token, "histories"),
docsHistoryListParams(spec),
nil,
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}
var DocsHistoryRevert = common.Shortcut{
Service: "docs",
Command: "+history-revert",
Description: "Revert a Lark document to a historical version",
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-revert"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true},
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil {
return err
}
if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil {
return err
}
return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
spec := docsHistoryRevertSpec{
Doc: ref,
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
}
return common.NewDryRunAPI().
Desc("OpenAPI: revert document history").
POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert").
Set("document_id", spec.Doc.Token).
Body(docsHistoryRevertBody(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
spec := docsHistoryRevertSpec{
Doc: ref,
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
}
data, err := runtime.CallAPITyped(
http.MethodPost,
docsHistoryAPIPath(spec.Doc.Token, "history/revert"),
nil,
docsHistoryRevertBody(spec),
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}
var DocsHistoryRevertStatus = common.Shortcut{
Service: "docs",
Command: "+history-revert-status",
Description: "Get Lark document history revert task status",
Risk: "read",
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-revert-status"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("task-id")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
spec := docsHistoryRevertStatusSpec{
Doc: ref,
TaskID: strings.TrimSpace(runtime.Str("task-id")),
}
return common.NewDryRunAPI().
Desc("OpenAPI: get document history revert status").
GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status").
Set("document_id", spec.Doc.Token).
Params(docsHistoryStatusParams(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
spec := docsHistoryRevertStatusSpec{
Doc: ref,
TaskID: strings.TrimSpace(runtime.Str("task-id")),
}
data, err := runtime.CallAPITyped(
http.MethodGet,
docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"),
docsHistoryStatusParams(spec),
nil,
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}

View File

@@ -1,340 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestDocsHistoryValidation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
shortcut common.Shortcut
args []string
param string
category errs.Category
subtype errs.Subtype
wantCause bool
}{
{
name: "list rejects legacy doc URL",
shortcut: DocsHistoryList,
args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"},
param: "--doc",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "list rejects invalid page size",
shortcut: DocsHistoryList,
args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"},
param: "--page-size",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "revert rejects non-numeric history version id",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "abc", "--as", "bot"},
param: "--history-version-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
wantCause: true,
},
{
name: "revert rejects non-positive history version id",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"},
param: "--history-version-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "revert rejects invalid wait timeout",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
param: "--wait-timeout-ms",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "status rejects empty task id",
shortcut: DocsHistoryRevertStatus,
args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"},
param: "--task-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-validation"))
err := mountAndRunDocs(t, tt.shortcut, tt.args, f, stdout)
if err == nil {
t.Fatal("expected validation error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
if problem.Category != tt.category {
t.Fatalf("category = %q, want %q (err: %v)", problem.Category, tt.category, err)
}
if problem.Subtype != tt.subtype {
t.Fatalf("subtype = %q, want %q (err: %v)", problem.Subtype, tt.subtype, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if validationErr.Param != tt.param {
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
}
if tt.wantCause && errors.Unwrap(err) == nil {
t.Fatalf("expected wrapped cause, got nil (err: %v)", err)
}
})
}
}
func TestDocsHistoryDryRun(t *testing.T) {
t.Parallel()
listCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryList, map[string]string{
"doc": "doxcnHistoryDryRun",
"page-size": "5",
"page-token": "page_token_1",
})
listDry := decodeDocDryRun(t, DocsHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
if got, want := listDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/histories"; got != want {
t.Fatalf("list dry-run URL = %q, want %q", got, want)
}
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
t.Fatalf("list page_size = %d, want 5", got)
}
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
t.Fatalf("list page_token = %#v, want page_token_1", got)
}
revertCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevert, map[string]string{
"doc": "doxcnHistoryDryRun",
"history-version-id": "42",
"wait-timeout-ms": "30000",
})
revertDry := decodeDocDryRun(t, DocsHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
if got, want := revertDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert"; got != want {
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
}
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
t.Fatalf("revert history_version_id = %#v, want 42", got)
}
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
}
statusCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevertStatus, map[string]string{
"doc": "doxcnHistoryDryRun",
"task-id": "task_1",
})
statusDry := decodeDocDryRun(t, DocsHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
if got, want := statusDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert_status"; got != want {
t.Fatalf("status dry-run URL = %q, want %q", got, want)
}
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
t.Fatalf("status task_id = %#v, want task_1", got)
}
}
func TestDocsHistoryExecuteList(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-list"))
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/histories",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"entries": []interface{}{
map[string]interface{}{
"revision_id": float64(42),
"history_version_id": "11",
"edit_time": "1780000000",
"type": float64(1),
"editor_ids": []interface{}{"ou_1"},
},
},
"has_more": true,
"page_token": "page_token_2",
},
},
}
reg.Register(stub)
err := mountAndRunDocs(t, DocsHistoryList, []string{
"+history-list",
"--doc", "doxcnHistory",
"--page-size", "5",
"--page-token", "page_token_1",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["page_token"]; got != "page_token_2" {
t.Fatalf("page_token = %#v, want page_token_2", got)
}
entries, _ := data["entries"].([]interface{})
if len(entries) != 1 {
t.Fatalf("entries = %#v, want one entry", data["entries"])
}
}
func TestDocsHistoryExecuteRevert(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-revert"))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"task_id": "task_1",
"status": "running",
"history_version_id": "42",
"poll_after_ms": float64(10000),
},
},
}
reg.Register(stub)
err := mountAndRunDocs(t, DocsHistoryRevert, []string{
"+history-revert",
"--doc", "doxcnHistory",
"--history-version-id", "42",
"--wait-timeout-ms", "0",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
}
if got := body["history_version_id"]; got != "42" {
t.Fatalf("history_version_id = %#v, want 42", got)
}
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
t.Fatalf("wait_timeout_ms = %d, want 0", got)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["task_id"]; got != "task_1" {
t.Fatalf("task_id = %#v, want task_1", got)
}
}
func TestDocsHistoryExecuteRevertStatus(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-status"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert_status",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"status": "partial_failed",
"history_version_id": "11",
"failed_block_tokens": []interface{}{"blk_1"},
},
},
})
err := mountAndRunDocs(t, DocsHistoryRevertStatus, []string{
"+history-revert-status",
"--doc", "doxcnHistory",
"--task-id", "task_1",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["status"]; got != "partial_failed" {
t.Fatalf("status = %#v, want partial_failed", got)
}
if got := data["history_version_id"]; got != "11" {
t.Fatalf("history_version_id = %#v, want 11", got)
}
failed, _ := data["failed_block_tokens"].([]interface{})
if len(failed) != 1 || failed[0] != "blk_1" {
t.Fatalf("failed_block_tokens = %#v, want [blk_1]", data["failed_block_tokens"])
}
}
func newDocsHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: shortcut.Command}
for _, flag := range shortcut.Flags {
switch flag.Type {
case "int":
cmd.Flags().Int(flag.Name, 0, flag.Desc)
default:
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
}
}
for name, value := range values {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return cmd
}
func decodeDocsHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in envelope: %#v", envelope)
}
return data
}
func TestDocsHistoryURLValidationMessage(t *testing.T) {
t.Parallel()
_, err := parseDocsHistoryDocRef("https://example.feishu.cn/doc/old_doc", "+history-list")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "only supports docx documents") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -31,8 +31,6 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
return docsSkillReadCommand + " references/lark-doc-fetch.md"
case "update":
return docsSkillReadCommand + " references/lark-doc-update.md"
case "history-list", "history-revert", "history-revert-status":
return docsSkillReadCommand + " references/lark-doc-history.md"
default:
return docsSkillReadCommand
}
@@ -46,12 +44,6 @@ func docsHelpCommandForShortcut(shortcut string) string {
return "lark-cli docs +fetch --help"
case "update":
return "lark-cli docs +update --help"
case "history-list":
return "lark-cli docs +history-list --help"
case "history-revert":
return "lark-cli docs +history-revert --help"
case "history-revert-status":
return "lark-cli docs +history-revert-status --help"
default:
return "lark-cli docs --help"
}
@@ -64,9 +56,6 @@ func Shortcuts() []common.Shortcut {
DocsCreate,
DocsFetch,
DocsUpdate,
DocsHistoryList,
DocsHistoryRevert,
DocsHistoryRevertStatus,
DocMediaInsert,
DocMediaUpload,
DocMediaPreview,

View File

@@ -8,7 +8,6 @@ import (
"encoding/json"
"fmt"
"path/filepath"
"strconv"
"strings"
"time"
@@ -29,8 +28,6 @@ const (
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
driveImportConcurrentOperationHint = "This import conflict means another operation is running in the same Drive location. Run batch imports to the same folder/root or target bitable serially. Wait a few seconds before retrying each failed import; retry each failed item at most 3 times, then stop and report the conflict."
)
// driveImportExtToDocTypes defines which source file extensions can be imported
@@ -50,8 +47,6 @@ var driveImportExtToDocTypes = map[string][]string{
"pptx": {"slides"},
}
var driveImportConcurrentOperationCodes = []int{232140101, 232140100, 233523001}
// driveImportSpec contains the user-facing import inputs after normalization.
type driveImportSpec struct {
FilePath string
@@ -432,7 +427,11 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
return status, true, nil
}
if status.Failed() {
return status, false, driveImportFailureError(status)
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
}
}
if !hadSuccessfulPoll && lastErr != nil {
@@ -441,40 +440,3 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
return lastStatus, false, nil
}
func driveImportFailureError(status driveImportStatus) *errs.APIError {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
apiErr := errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
if code, ok := driveImportConcurrentOperationCode(msg); ok {
apiErr = apiErr.WithCode(code).WithRetryable().WithHint(driveImportConcurrentOperationHint)
}
return apiErr
}
func driveImportConcurrentOperationCode(msg string) (int, bool) {
for _, code := range driveImportConcurrentOperationCodes {
codeText := strconv.Itoa(code)
for idx := strings.Index(msg, codeText); idx >= 0; {
end := idx + len(codeText)
if (idx == 0 || !isASCIIDigit(msg[idx-1])) && (end == len(msg) || !isASCIIDigit(msg[end])) {
return code, true
}
nextStart := idx + 1
next := strings.Index(msg[nextStart:], codeText)
if next < 0 {
break
}
idx = nextStart + next
}
}
return 0, false
}
func isASCIIDigit(ch byte) bool {
return ch >= '0' && ch <= '9'
}

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"errors"
"os"
"strconv"
"strings"
"testing"
@@ -212,82 +211,6 @@ func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
}
}
func TestDriveImportFailureErrorAddsConcurrentOperationGuidance(t *testing.T) {
t.Parallel()
for _, code := range driveImportConcurrentOperationCodes {
t.Run(strconv.Itoa(code), func(t *testing.T) {
t.Parallel()
err := driveImportFailureError(driveImportStatus{
JobStatus: 3,
JobErrorMsg: "call CreateObjNode return error code, code: " + strconv.Itoa(code) + ", message:",
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Category != errs.CategoryAPI {
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryAPI)
}
if problem.Subtype != errs.SubtypeServerError {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeServerError)
}
if problem.Code != code {
t.Fatalf("code = %d, want %d", problem.Code, code)
}
if !problem.Retryable {
t.Fatal("expected retryable error")
}
if problem.Hint != driveImportConcurrentOperationHint {
t.Fatalf("hint = %q, want %q", problem.Hint, driveImportConcurrentOperationHint)
}
})
}
}
func TestDriveImportFailureErrorLeavesOtherFailuresUnchanged(t *testing.T) {
t.Parallel()
tests := []struct {
name string
msg string
}{
{
name: "ordinary failure",
msg: "unsupported conversion",
},
{
name: "longer numeric code containing known code",
msg: "call CreateObjNode return error code, code: 12321401012, message:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := driveImportFailureError(driveImportStatus{
JobStatus: 3,
JobErrorMsg: tt.msg,
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Code != 0 {
t.Fatalf("code = %d, want 0", problem.Code)
}
if problem.Retryable {
t.Fatal("expected non-concurrency failure to remain non-retryable")
}
if problem.Hint != "" {
t.Fatalf("hint = %q, want empty", problem.Hint)
}
})
}
}
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{

View File

@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (may be empty to browse by filter only); max 30 characters by Unicode code point (CJK counts 1 each), over 30 the server rejects with 99992402 field validation failed"},
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},

View File

@@ -66,24 +66,31 @@ var MinutesSpeakerReplace = common.Shortcut{
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
return common.NewDryRunAPI().
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
dr := common.NewDryRunAPI()
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" {
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
}
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
Body(buildSpeakerReplaceRequestBody(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id"))
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
_, err := runtime.CallAPITyped(http.MethodPut,
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken)
if err != nil {
return err
}
_, err = runtime.CallAPITyped(http.MethodPut,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
if err != nil {
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID))
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID))
}
runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
return nil
},
}
@@ -107,20 +114,26 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
return body
}
func buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
out := map[string]interface{}{
"minute_token": minuteToken,
"to_user_id": toUserID,
}
if fromSpeakerID != "" {
out["from_speaker_id"] = fromSpeakerID
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
out["from_speaker_input"] = fromSpeakerInput
}
} else {
out["from_user_id"] = fromUserID
}
return out
}
func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string {
if fromSpeakerInput != "" {
return fromSpeakerInput
}
if fromSpeakerID != "" {
return fromSpeakerID
}

View File

@@ -153,14 +153,58 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
}
}
func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-speaker-id", "说话人1",
"--to-user-id", "ou_new_speaker",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "GET") {
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
}
if !strings.Contains(out, "/transcript/speakerlist") {
t.Errorf("expected speakerlist path, got:\n%s", out)
}
if !strings.Contains(out, "PUT") {
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
}
if !strings.Contains(out, "ou_new_speaker") {
t.Errorf("expected to_user_id in body, got:\n%s", out)
}
}
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
// Only the PUT is registered on purpose: an opaque speaker_id must be passed
// straight through without a second speakerlist call. If the code still
// prefetched speakerlist, the unregistered GET would fail the request.
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"speakers": []interface{}{
map[string]interface{}{
"speaker_id": "ENCRYPTED_TOKEN_ABC",
"name": "说话人1",
},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: http.MethodPut,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
@@ -174,7 +218,7 @@ func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
"--from-speaker-id", "说话人1",
"--to-user-id", "ou_new_speaker",
"--format", "json", "--as", "user",
}, f, stdout)
@@ -184,19 +228,21 @@ func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
var envelope struct {
Data struct {
FromSpeakerID string `json:"from_speaker_id"`
ToUserID string `json:"to_user_id"`
MinuteToken string `json:"minute_token"`
FromSpeakerInput string `json:"from_speaker_input"`
FromSpeakerID string `json:"from_speaker_id"`
ToUserID string `json:"to_user_id"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Data.FromSpeakerInput != "说话人1" {
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
}
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
}
if envelope.Data.ToUserID != "ou_new_speaker" {
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
}
}
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
@@ -216,11 +262,8 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
}
out := stdout.String()
if strings.Contains(out, "/transcript/speakerlist") {
t.Errorf("opaque speaker_id should not prefetch speakerlist, got:\n%s", out)
}
if !strings.Contains(out, "PUT") {
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
if !strings.Contains(out, "GET") {
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
}
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
t.Errorf("expected from_speaker_id in body, got:\n%s", out)

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type minuteSpeaker struct {
SpeakerID string
Name string
}
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
}
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
if err != nil {
return nil, err
}
if data == nil {
return nil, nil
}
items := common.GetSlice(data, "speakers")
speakers := make([]minuteSpeaker, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
name := strings.TrimSpace(common.GetString(item, "name"))
if id == "" {
continue
}
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
}
return speakers, nil
}
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
name = strings.TrimSpace(name)
var matches []minuteSpeaker
for _, s := range speakers {
if s.Name == name {
matches = append(matches, s)
}
}
switch len(matches) {
case 0:
return "", errs.NewValidationError(errs.SubtypeNotFound,
"no speaker named %q in minute transcript", name).
WithParam("--from-speaker-id").
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
case 1:
return matches[0].SpeakerID, nil
default:
ids := make([]string, len(matches))
for i, m := range matches {
ids[i] = m.SpeakerID
}
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
WithParam("--from-speaker-id").
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
}
}
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
// The input may already be an opaque speaker_id, or a display name that requires
// an internal speaker-list fetch.
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
input = strings.TrimSpace(input)
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
if err != nil {
return "", err
}
for _, s := range speakers {
if s.SpeakerID == input {
return input, nil
}
}
return resolveSpeakerIDByName(speakers, input)
}
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
if fromUserID != "" {
return "", fromUserID, nil
}
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
return fromSpeakerID, "", err
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func TestResolveSpeakerIDByName(t *testing.T) {
speakers := []minuteSpeaker{
{SpeakerID: "id_a", Name: "Alice"},
{SpeakerID: "id_b", Name: "Bob"},
{SpeakerID: "id_c", Name: "Alice"},
}
id, err := resolveSpeakerIDByName(speakers, "Bob")
if err != nil || id != "id_b" {
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
}
_, err = resolveSpeakerIDByName(speakers, "Carol")
if err == nil {
t.Fatal("expected not found error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
t.Fatalf("want not-found validation error, got %T: %v", err, err)
}
_, err = resolveSpeakerIDByName(speakers, "Alice")
if err == nil {
t.Fatal("expected duplicate name error")
}
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
}
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
}
}

View File

@@ -205,16 +205,18 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
}
// Sheets backward-compatibility help grouping.
// Sheets backward-compatibility 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.
// upgrading only the binary. applySheetsCompatGroups tags each alias into a
// dedicated deprecated cobra group. The refactored commands have been the
// default for over a month, so `sheets --help` no longer lists these aliases:
// sheetsUsageTemplate renders every group except the deprecated one. The
// grouping is still applied for two reasons — the unknown-subcommand path
// (cmd/root.go) keys off it to classify a mistyped legacy alias, and each
// alias's own `sheets <alias> --help` still surfaces the "(→ +new-command)"
// migration pointer appended below. The aliases stay fully executable.
const (
sheetsCurrentGroupID = "sheets-current"
// sheetsDeprecatedGroupID aliases the shared deprecated-group id so both
@@ -224,9 +226,10 @@ const (
)
// 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.
// command(s) that replace it, shown as a "(→ ...)" suffix in the alias's own
// --help and reused by wrapSheetsBackwardDeprecation for the on-execution
// _notice. Aliases absent from this map still land in the deprecated group,
// just without a pointer, so a missing entry degrades gracefully.
var sheetsAliasReplacement = map[string]string{
// spreadsheet / sheet management
"+create": "+workbook-create",
@@ -279,6 +282,43 @@ var sheetsAliasReplacement = map[string]string{
"+delete-float-image": "+float-image-delete",
}
// sheetsUsageTemplate is cobra v1.10.2's stock usage template with a single
// change: the group loop is guarded by {{if ne $group.ID "deprecated"}} so the
// deprecated pre-refactor aliases are omitted from `sheets --help` altogether.
// Everything else — current commands, ungrouped metaapi subcommands under
// "Additional Commands", flags — renders exactly as cobra's default. Keep in
// sync with cobra's defaultUsageTemplate on upgrade.
var sheetsUsageTemplate = fmt.Sprintf(`Usage:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
Aliases:
{{.NameAndAliases}}{{end}}{{if .HasExample}}
Examples:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}{{if ne $group.ID %q}}
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
`, sheetsDeprecatedGroupID)
func applySheetsCompatGroups(svc *cobra.Command) {
svc.AddGroup(
&cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"},
@@ -310,6 +350,11 @@ func applySheetsCompatGroups(svc *cobra.Command) {
c.GroupID = sheetsCurrentGroupID
}
}
// Refactored commands have been the default for over a month: drop the
// deprecated group from `sheets --help` (see sheetsUsageTemplate). The
// aliases remain grouped and executable, just no longer advertised here.
svc.SetUsageTemplate(sheetsUsageTemplate)
}
// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets

View File

@@ -170,27 +170,6 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
}
}
func TestRegisterShortcutsMountsDocsHistoryCommands(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
for _, name := range []string{"+history-list", "+history-revert", "+history-revert-status"} {
cmd, _, err := program.Find([]string{"docs", name})
if err != nil {
t.Fatalf("find docs %s shortcut: %v", name, err)
}
if cmd == nil || cmd.Name() != name {
t.Fatalf("docs %s shortcut not mounted: %#v", name, cmd)
}
if cmd.Flags().Lookup("api-version") != nil {
t.Fatalf("docs %s should not expose --api-version", name)
}
if !strings.Contains(cmd.Long, "lark-cli skills read lark-doc references/lark-doc-history.md") {
t.Fatalf("docs %s help missing history skill guidance:\n%s", name, cmd.Long)
}
}
}
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
@@ -532,10 +511,11 @@ func TestApplySheetsCompatGroups(t *testing.T) {
}
}
// 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) {
// End-to-end: `sheets --help` must list refactored shortcuts under Available
// Commands, but no longer advertise the deprecated pre-refactor aliases or the
// deprecated group heading (sheetsUsageTemplate skips that group). The aliases
// stay registered and executable — hidden from the parent listing, not removed.
func TestRegisterShortcutsSheetsHelpHidesDeprecatedAliases(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
@@ -551,19 +531,25 @@ func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) {
}
got := out.String()
for _, want := range []string{
"Available Commands:",
"Deprecated pre-refactor commands",
"update your lark-sheets skill",
"+read",
"(→ +cells-get)",
"+write",
"(→ +cells-set)",
} {
for _, want := range []string{"Available Commands:", "+cells-get"} {
if !strings.Contains(got, want) {
t.Fatalf("sheets help missing %q:\n%s", want, got)
}
}
for _, unwanted := range []string{
"Deprecated pre-refactor commands",
"update your lark-sheets skill",
"+read",
"+write",
} {
if strings.Contains(got, unwanted) {
t.Fatalf("sheets help still shows deprecated content %q:\n%s", unwanted, got)
}
}
if alias, _, ferr := sheetsCmd.Find([]string{"+read"}); ferr != nil || alias == nil {
t.Fatalf("deprecated alias +read should stay registered, got err=%v cmd=%v", ferr, alias)
}
}
// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that

View File

@@ -5,6 +5,7 @@ package backward
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
@@ -17,20 +18,30 @@ import (
// Drive media parent_type values for uploading an image into a spreadsheet.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
// synthetic token prefixed with "fake_office_" and the backend requires
// "office_sheet_file" instead.
// synthetic token prefixed with "fake_office_" (being renamed to
// "local_office_") and the backend requires "office_sheet_file" instead.
const (
sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file"
fakeOfficeTokenPrefix = "fake_office_"
localOfficeTokenPrefix = "local_office_"
)
// officeTokenPrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
var officeTokenPrefixes = []string{fakeOfficeTokenPrefix, localOfficeTokenPrefix}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken, mapping the
// "fake_office_" imported-spreadsheet token prefix to "office_sheet_file".
// uploading an image whose parent_node is spreadsheetToken, mapping either the
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
// "office_sheet_file".
func sheetMediaParentType(spreadsheetToken string) string {
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
return officeSheetFileParentType
for _, prefix := range officeTokenPrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return officeSheetFileParentType
}
}
return sheetImageParentType
}
@@ -135,7 +146,8 @@ func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath strin
stat, err := runtime.FileIO().Stat(filePath)
if err != nil {
wrapped := common.WrapInputStatErrorTyped(err, "file not found")
if v, ok := wrapped.(*errs.ValidationError); ok {
var v *errs.ValidationError
if errors.As(wrapped, &v) {
return "", nil, v.WithParam("--file")
}
return "", nil, wrapped

View File

@@ -102,8 +102,8 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
{
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}`,
args: []string{"--sheet-id", "sh1", "--range", "1", "--height", "30"},
subInput: `{"sheet-id":"sh1","range":"1","height":30}`,
},
{
shortcut: "+cols-resize",
@@ -409,12 +409,12 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
wantContains: "--count must be > 0",
},
{
name: "+rows-resize --type pixel without --size",
name: "+rows-resize --height with --type standard",
shortcut: RowsResize,
args: []string{"--sheet-id", "sh1", "--range", "1:2", "--type", "pixel"},
args: []string{"--sheet-id", "sh1", "--range", "1:2", "--height", "30", "--type", "standard"},
subShortcut: "+rows-resize",
subInput: `{"sheet-id":"sh1","range":"1:2","type":"pixel"}`,
wantContains: "--type pixel requires --size",
subInput: `{"sheet-id":"sh1","range":"1:2","height":30,"type":"standard"}`,
wantContains: "--height cannot be combined with --type standard",
},
{
name: "+sheet-delete missing sheet selector",
@@ -469,6 +469,34 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
}
}
// TestBatchOp_RejectsResizeMapForm locks the nesting guard: the map form
// (--widths/--heights) expands into its own batch_update, and batch_update
// cannot nest, so a +batch-update sub-op carrying `widths`/`heights` must be
// rejected with a pointer to the standalone form — it is standalone-valid,
// so this case cannot live in the standalone-vs-batch equivalence table.
func TestBatchOp_RejectsResizeMapForm(t *testing.T) {
t.Parallel()
cases := []struct {
shortcut string
input string
}{
{"+cols-resize", `{"sheet-id":"sh1","widths":{"A":100}}`},
{"+rows-resize", `{"sheet-id":"sh1","heights":{"1":50}}`},
}
for _, tc := range cases {
t.Run(tc.shortcut, func(t *testing.T) {
t.Parallel()
var subInput map[string]interface{}
if err := json.Unmarshal([]byte(tc.input), &subInput); err != nil {
t.Fatalf("bad input JSON: %v", err)
}
rawOp := map[string]interface{}{"shortcut": tc.shortcut, "input": subInput}
_, err := translateBatchOp(rawOp, testToken, 0)
requireValidation(t, err, "not supported inside +batch-update")
})
}
}
// 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
@@ -611,10 +639,10 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
"--position is required",
},
{
"+rows-resize missing --type",
"+rows-resize missing both --height and --type",
"+rows-resize",
`{"sheet-id":"sh1","range":"1:1"}`,
"--type is required",
"give --height <px> for a pixel size, or --type standard / auto",
},
{
"+range-copy missing --target-range",
@@ -802,7 +830,7 @@ func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) {
// 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}}]`,
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","height":40}}]`,
"--yes",
})
ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{})

View File

@@ -4,6 +4,7 @@
package sheets
import (
"sort"
"strings"
)
@@ -118,10 +119,19 @@ var batchOpDispatch = map[string]batchOpMapping{
}},
// ─── 行高列宽 (resize_range, 无 operation 字段) ─────────────────
// The map form (--heights/--widths) fans out into its own batch_update
// and cannot nest inside +batch-update; sub-ops must use the uniform
// single-range form (range + height/width or type).
"+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
if err := rejectResizeMapInBatch(fv, "row"); err != nil {
return nil, err
}
return resizeInput(fv, token, sid, sname, "row")
}},
"+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
if err := rejectResizeMapInBatch(fv, "column"); err != nil {
return nil, err
}
return resizeInput(fv, token, sid, sname, "column")
}},
@@ -197,6 +207,54 @@ var batchOpDispatch = map[string]batchOpMapping{
"+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)},
}
// allowedBatchShortcuts lists every shortcut accepted inside +batch-update,
// sorted, for the not-allowed error hint.
func allowedBatchShortcuts() []string {
out := make([]string, 0, len(batchOpDispatch))
for sc := range batchOpDispatch {
out = append(out, sc)
}
sort.Strings(out)
return out
}
// subOpInputContract renders one shortcut's complete sub-op key vocabulary
// (wire-style underscore names) for the translator-failure hint: required
// flags are marked, the sheet selector pair collapses to a choose-one, and
// spreadsheet locators are omitted (reserved for the batch top level).
// Returns "" for shortcuts without a flag-defs entry.
func subOpInputContract(sc string) string {
defs, _ := loadFlagDefs()
spec, ok := defs[sc]
if !ok {
return ""
}
idFlag, nameFlag := sheetSelectorFlagsForSubOp(sc)
var keys []string
sheetSelector := ""
for _, df := range spec.Flags {
if df.Kind == "system" || df.Hidden {
continue
}
switch df.Name {
case "url", "spreadsheet-token":
continue // reserved: supplied by +batch-update top level
case idFlag, nameFlag:
sheetSelector = strings.ReplaceAll(idFlag, "-", "_") + "|" + strings.ReplaceAll(nameFlag, "-", "_") + " (choose one)"
continue
}
key := strings.ReplaceAll(df.Name, "-", "_")
if df.Required == "required" {
key += " (required)"
}
keys = append(keys, key)
}
if sheetSelector != "" {
keys = append([]string{sheetSelector}, keys...)
}
return strings.Join(keys, ", ")
}
// 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.
@@ -262,7 +320,8 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
}
scRaw, present := op["shortcut"]
if !present {
return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' field is required", index)
return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' field is required", index).
WithHint(`each entry must look like {"shortcut":"+cells-set","input":{"sheet_name":"…","range":"A1:B2","cells":[[…]]}} — input uses the shortcut's own flag names`)
}
sc, ok := scRaw.(string)
if !ok || sc == "" {
@@ -270,13 +329,15 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
}
mapping, ok := batchOpDispatch[sc]
if !ok {
// Inline the full allow-list: an agent that guessed a read op or a
// fan-out wrapper can pick the right shortcut immediately instead of
// spending a --print-schema round trip on the operations enum.
return nil, sheetsValidationForFlag(
"operations",
"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)",
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
index, sc,
)
).WithHint("allowed shortcuts: %s", strings.Join(allowedBatchShortcuts(), ", "))
}
inputRaw, hasInput := op["input"]
var input map[string]interface{}
@@ -324,7 +385,14 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
sheetName := strings.TrimSpace(fv.Str(sheetNameFlag))
body, err := mapping.translate(fv, token, sheetID, sheetName)
if err != nil {
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
// The inner error names one problem at a time (first missing flag);
// the hint lists the sub-op's complete key contract so an agent fixes
// every gap in a single retry instead of iterating flag by flag.
verr := sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
if contract := subOpInputContract(sc); contract != "" {
verr = verr.WithHint("%s input keys: %s", sc, contract)
}
return nil, verr
}
return map[string]interface{}{
"tool_name": mapping.mcpToolName,
@@ -332,11 +400,23 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
}, nil
}
// maxBatchOperations caps how many sub-operations a single +batch-update may
// carry. Every translated op (with its own cells/properties payload) is held in
// the out slice at once before the whole batch is marshaled, so an unbounded
// operation count is the same unbounded-materialization hazard as the fan-out
// matrix, on the operations axis.
const maxBatchOperations = 100
// translateBatchOperations 翻译整个 ops 数组fail-fast遇错立即返回。
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
if len(rawOps) == 0 {
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
}
if len(rawOps) > maxBatchOperations {
batches := (len(rawOps) + maxBatchOperations - 1) / maxBatchOperations
return nil, sheetsValidationForFlag("operations", "--operations accepts at most %d entries; got %d", maxBatchOperations, len(rawOps)).
WithHint("split the operations into %d separate +batch-update calls of at most %d entries each", batches, maxBatchOperations)
}
out := make([]interface{}, 0, len(rawOps))
for i, raw := range rawOps {
translated, err := translateBatchOp(raw, token, i)

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// +csv-put lets a piped CSV satisfy an omitted --csv: agents routinely redirect
// a file into stdin but forget the `--csv -`. PostMount relaxes the required
// gate and installs a PreRunE that, when stdin is a non-interactive pipe,
// defaults an absent --csv to "-" so the standard stdin path reads it. On an
// interactive terminal the fallback stays off so the command never blocks.
func mountCsvPut(t *testing.T) *cobra.Command {
t.Helper()
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "sheets"}
CsvPut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+csv-put"})
if err != nil {
t.Fatalf("Find(+csv-put) error = %v", err)
}
return cmd
}
func csvRequiredAnnotationPresent(cmd *cobra.Command) bool {
fl := cmd.Flags().Lookup("csv")
if fl == nil {
return false
}
_, ok := fl.Annotations[cobra.BashCompOneRequiredFlag]
return ok
}
// withStdinIsPipe swaps the package-level pipe detector for the duration of a
// test so behavior does not depend on the real process stdin.
func withStdinIsPipe(t *testing.T, piped bool) {
t.Helper()
prev := csvPutStdinIsPipe
csvPutStdinIsPipe = func() bool { return piped }
t.Cleanup(func() { csvPutStdinIsPipe = prev })
}
func TestCsvPutPostMount_RelaxesCsvRequired(t *testing.T) {
cmd := mountCsvPut(t)
if csvRequiredAnnotationPresent(cmd) {
t.Error("--csv required annotation should be relaxed so csvPutInput reports the typed error")
}
if cmd.PreRunE == nil {
t.Fatal("PostMount should install a PreRunE for the stdin fallback")
}
}
func TestCsvPutPreRunE_PipedAbsentDefaultsToDash(t *testing.T) {
withStdinIsPipe(t, true)
cmd := mountCsvPut(t)
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("PreRunE error = %v", err)
}
if got, _ := cmd.Flags().GetString("csv"); got != "-" {
t.Errorf("csv = %q, want %q (piped + absent should default to '-')", got, "-")
}
}
func TestCsvPutPreRunE_InteractiveAbsentStaysEmpty(t *testing.T) {
withStdinIsPipe(t, false)
cmd := mountCsvPut(t)
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("PreRunE error = %v", err)
}
if got, _ := cmd.Flags().GetString("csv"); got != "" {
t.Errorf("csv = %q, want empty (interactive stdin must not be consumed / must not hang)", got)
}
}
func TestCsvPutPreRunE_ExplicitValueUnchanged(t *testing.T) {
withStdinIsPipe(t, true)
cmd := mountCsvPut(t)
if err := cmd.Flags().Set("csv", "x,y"); err != nil {
t.Fatalf("Set(csv) error = %v", err)
}
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("PreRunE error = %v", err)
}
if got, _ := cmd.Flags().GetString("csv"); got != "x,y" {
t.Errorf("csv = %q, want %q (explicit value must not be overridden)", got, "x,y")
}
}

View File

@@ -1,4 +1,59 @@
{
"+formula-verify": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string_slice",
"required": "optional",
"desc": "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
},
{
"name": "sheet-name",
"kind": "public",
"type": "string_slice",
"required": "optional",
"desc": "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
},
{
"name": "range",
"kind": "own",
"type": "string_slice",
"required": "optional",
"desc": "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."
},
{
"name": "max-locations",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max locations / samples per error type; default 20.",
"default": "20"
},
{
"name": "exit-on-error",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."
}
]
},
"+workbook-info": {
"risk": "read",
"flags": [
@@ -25,6 +80,32 @@
}
]
},
"+revision-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+sheet-create": {
"risk": "write",
"flags": [
@@ -73,6 +154,18 @@
"desc": "Initial column count (default 20, max 200)",
"default": "20"
},
{
"name": "type",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands",
"default": "sheet",
"enum": [
"sheet",
"bitable"
]
},
{
"name": "dry-run",
"kind": "system",
@@ -219,7 +312,7 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`",
"desc": "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it",
"default": "-1"
},
{
@@ -515,7 +608,7 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
"input": [
"file",
"stdin"
@@ -1069,7 +1162,7 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Group nesting level to ungroup; default 1 (outermost)",
"desc": "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)",
"default": "1"
},
{
@@ -1711,6 +1804,13 @@
"required": "optional",
"desc": "Font color (hex, e.g. `#000000`)"
},
{
"name": "font-family",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
},
{
"name": "font-size",
"kind": "own",
@@ -2294,32 +2394,43 @@
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "height",
"kind": "own",
"type": "int",
"required": "xor",
"desc": "Uniform row height in pixels (e.g. 30 / 40 / 60; NOT points), used with `--range`. Passing --height implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-row heights use `--heights`",
"default": "0"
},
{
"name": "heights",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Per-row height map — set different heights for many rows in one atomic call. Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`",
"input": [
"file",
"stdin"
]
},
{
"name": "type",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)",
"required": "xor",
"desc": "Sizing mode: `pixel` (requires `--height`) / `standard` (reset to default row height) / `auto` (fit content). Passing --height alone is the common form; `--type standard` / `--type auto` cannot be combined with `--height`",
"enum": [
"pixel",
"standard",
"auto"
]
},
{
"name": "size",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise",
"default": "0"
},
{
"name": "range",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"
"required": "xor",
"desc": "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row). Required for the uniform form (with `--height` or `--type`); omit with the map form (`--heights`)"
},
{
"name": "dry-run",
@@ -2361,31 +2472,42 @@
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "width",
"kind": "own",
"type": "int",
"required": "xor",
"desc": "Uniform column width in pixels (e.g. 80 / 120 / 200; NOT Excel character units), used with `--range`. Passing --width implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-column widths use `--widths`",
"default": "0"
},
{
"name": "widths",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Per-column width map — set different widths for many columns in one atomic call. Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`",
"input": [
"file",
"stdin"
]
},
{
"name": "type",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)",
"required": "xor",
"desc": "Sizing mode: `pixel` (requires `--width`) / `standard` (reset to default column width). Passing --width alone is the common form; `--type standard` cannot be combined with `--width`",
"enum": [
"pixel",
"standard"
]
},
{
"name": "size",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise",
"default": "0"
},
{
"name": "range",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Column closed range to resize; column letters like `A:E` or `C` (single column)"
"required": "xor",
"desc": "Column closed range to resize; column letters like `A:E` or `C` (single column). Required for the uniform form (with `--width` or `--type`); omit with the map form (`--widths`)"
},
{
"name": "dry-run",
@@ -2739,7 +2861,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
"input": [
"file",
"stdin"
@@ -2759,6 +2881,13 @@
"required": "optional",
"desc": "Font color (hex, e.g. `#000000`)"
},
{
"name": "font-family",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
},
{
"name": "font-size",
"kind": "own",
@@ -2885,7 +3014,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
"input": [
"file",
"stdin"
@@ -2965,7 +3094,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
"input": [
"file",
"stdin"
@@ -3009,7 +3138,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
"input": [
"file",
"stdin"
@@ -3127,7 +3256,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
"input": [
"file",
"stdin"
@@ -4066,7 +4195,7 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"
"desc": "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"
},
{
"name": "dry-run",
@@ -4747,5 +4876,138 @@
"desc": ""
}
]
},
"+history-list": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "end-version",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+history-revert": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "history-version-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "History version to revert to (from +history-list)."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+history-revert-status": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "transaction-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Async revert transaction id (from +history-revert)."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+changeset-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "start-revision",
"kind": "own",
"type": "int",
"required": "required",
"desc": "Start version (CS revision); the before baseline for review (must be >= 1)"
},
{
"name": "end-revision",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20",
"default": "-1"
}
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -27,7 +27,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -38,9 +38,10 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
@@ -165,6 +166,7 @@ var flagDefs = map[string]commandDef{
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
@@ -188,6 +190,15 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+changeset-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "start-revision", Kind: "own", Type: "int", Required: "required", Desc: "Start version (CS revision); the before baseline for review (must be >= 1)"},
{Name: "end-revision", Kind: "own", Type: "int", Required: "optional", Desc: "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20", Default: "-1"},
},
},
"+chart-create": {
Risk: "write",
Flags: []flagDef{
@@ -195,7 +206,7 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
@@ -241,9 +252,10 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)", Enum: []string{"pixel", "standard"}},
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise", Default: "0"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column)"},
{Name: "width", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform column width in pixels (e.g. 80 / 120 / 200; NOT Excel character units), used with `--range`. Passing --width implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-column widths use `--widths`", Default: "0"},
{Name: "widths", Kind: "own", Type: "string", Required: "xor", Desc: "Per-column width map — set different widths for many columns in one atomic call. Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`", Input: []string{"file", "stdin"}},
{Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--width`) / `standard` (reset to default column width). Passing --width alone is the common form; `--type standard` cannot be combined with `--width`", Enum: []string{"pixel", "standard"}},
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column). Required for the uniform form (with `--width` or `--type`); omit with the map form (`--widths`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -405,7 +417,7 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"},
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)", Default: "1"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -426,7 +438,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -463,7 +475,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
@@ -526,7 +538,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -632,6 +644,45 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+formula-verify": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
{Name: "sheet-name", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
{Name: "range", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."},
{Name: "max-locations", Kind: "own", Type: "int", Required: "optional", Desc: "Max locations / samples per error type; default 20.", Default: "20"},
{Name: "exit-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."},
},
},
"+history-list": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "end-version", Kind: "own", Type: "int", Required: "optional", Desc: "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+history-revert": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "history-version-id", Kind: "own", Type: "string", Required: "required", Desc: "History version to revert to (from +history-list)."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+history-revert-status": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "transaction-id", Kind: "own", Type: "string", Required: "required", Desc: "Async revert transaction id (from +history-revert)."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+pivot-create": {
Risk: "write",
Flags: []flagDef{
@@ -734,6 +785,14 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+revision-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+rows-resize": {
Risk: "write",
Flags: []flagDef{
@@ -741,9 +800,10 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)", Enum: []string{"pixel", "standard", "auto"}},
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise", Default: "0"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"},
{Name: "height", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform row height in pixels (e.g. 30 / 40 / 60; NOT points), used with `--range`. Passing --height implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-row heights use `--heights`", Default: "0"},
{Name: "heights", Kind: "own", Type: "string", Required: "xor", Desc: "Per-row height map — set different heights for many rows in one atomic call. Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`", Input: []string{"file", "stdin"}},
{Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--height`) / `standard` (reset to default row height) / `auto` (fit content). Passing --height alone is the common form; `--type standard` / `--type auto` cannot be combined with `--height`", Enum: []string{"pixel", "standard", "auto"}},
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row). Required for the uniform form (with `--height` or `--type`); omit with the map form (`--heights`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -768,6 +828,7 @@ var flagDefs = map[string]commandDef{
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
{Name: "type", Kind: "own", Type: "string", Required: "optional", Desc: "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands", Default: "sheet", Enum: []string{"sheet", "bitable"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -822,7 +883,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", Default: "-1"},
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it", Default: "-1"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -941,7 +1002,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},

View File

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

View File

@@ -0,0 +1,296 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestUnknownFlagFromParseError(t *testing.T) {
t.Parallel()
cases := []struct {
in string
name string
ok bool
}{
{"unknown flag: --cols", "cols", 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 := unknownFlagFromParseError(errors.New(c.in))
if name != c.name || ok != c.ok {
t.Errorf("unknownFlagFromParseError(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
}
}
}
// TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags pins the sheets
// override of the root unknown-flag error: --cols is a semantic guess for
// --range that edit distance can't rank, so the hint must inline the full
// valid-flag list instead of deferring to a --help round trip.
func TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().Int("width", 0, "")
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --cols"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--cols" {
t.Errorf("Params = %v, want one entry named --cols", verr.Params)
}
if strings.Contains(verr.Hint, "--help") {
t.Errorf("hint should not defer to --help when flags fit inline, got %q", verr.Hint)
}
for _, want := range []string{"--range", "--width"} {
if !strings.Contains(verr.Hint, want) {
t.Errorf("hint should inline valid flag %s, got %q", want, verr.Hint)
}
}
}
// TestSheetsFlagErrorFunc_TypoKeepsSuggestion pins that the root behavior
// (did-you-mean suggestion, machine-readable Suggestions) is preserved by
// the sheets override, with the valid-flag list appended.
func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().Bool("dry-run", false, "")
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --rang"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
found := false
for _, s := range verr.Params[0].Suggestions {
if s == "--range" {
found = true
}
}
if !found {
t.Errorf("Suggestions should include --range, got %v", verr.Params[0].Suggestions)
}
for _, want := range []string{"did you mean", "--range", "--dry-run"} {
if !strings.Contains(verr.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, verr.Hint)
}
}
}
func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "demo"}
err := sheetsFlagErrorFunc(c, errors.New("flag needs an argument: --find"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Param != "" || len(verr.Params) != 0 {
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
}
}
func TestInlineFlagList_TruncatesPastLimit(t *testing.T) {
t.Parallel()
if got := inlineFlagList(nil); got != "" {
t.Errorf("inlineFlagList(nil) = %q, want empty", got)
}
names := make([]string, inlineFlagListLimit+5)
for i := range names {
names[i] = fmt.Sprintf("flag-%02d", i)
}
got := inlineFlagList(names)
if !strings.Contains(got, "5 more") || !strings.Contains(got, "--help") {
t.Errorf("truncated list should count the overflow and defer to --help, got %q", got)
}
if strings.Contains(got, names[inlineFlagListLimit]) {
t.Errorf("list should stop at the limit, got %q", got)
}
}
func TestCanonicalEnumValue(t *testing.T) {
t.Parallel()
cases := []struct {
val string
enum []string
want string
}{
{"SUM", []string{"sum", "count"}, "sum"}, // casing
{"center", []string{"top", "middle", "bottom"}, "middle"}, // alias: CSS vertical center
{"middle", []string{"left", "center", "right"}, "center"}, // alias: horizontal middle
{"overwite", []string{"append", "overwrite"}, ""}, // typo is NOT canonical
{"delete", []string{"append", "overwrite"}, ""}, // nothing close
}
for _, c := range cases {
if got := canonicalEnumValue(c.val, c.enum); got != c.want {
t.Errorf("canonicalEnumValue(%q, %v) = %q, want %q", c.val, c.enum, got, c.want)
}
}
}
func TestClosestEnumValue(t *testing.T) {
t.Parallel()
cases := []struct {
val string
enum []string
want string
}{
{"SUM", []string{"sum", "count"}, "sum"}, // casing
{"center", []string{"top", "middle", "bottom"}, "middle"}, // alias
{"overwite", []string{"append", "overwrite"}, "overwrite"}, // edit distance
{"delete", []string{"append", "overwrite"}, ""}, // nothing close
}
for _, c := range cases {
if got := closestEnumValue(c.val, c.enum); got != c.want {
t.Errorf("closestEnumValue(%q, %v) = %q, want %q", c.val, c.enum, got, c.want)
}
}
}
// TestChainEnumNormalization_UnitContract pins the PreRunE stage in
// isolation: canonical vocabulary is auto-applied, typos error with a
// suggestion (never applied), the framework PreRunE keeps running first,
// and --print-schema skips enum gating entirely.
func TestChainEnumNormalization_UnitContract(t *testing.T) {
t.Parallel()
newCmd := func() (*cobra.Command, *bool) {
cmd := &cobra.Command{Use: "+cells-set-style"}
cmd.Flags().String("vertical-alignment", "", "")
cmd.Flags().Bool("print-schema", false, "")
prevCalled := false
cmd.PreRunE = func(*cobra.Command, []string) error {
prevCalled = true
return nil
}
chainEnumNormalization(cmd)
return cmd, &prevCalled
}
// Alias auto-applied, framework PreRunE preserved.
cmd, prevCalled := newCmd()
cmd.Flags().Set("vertical-alignment", "center")
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("center should normalize and pass, got: %v", err)
}
if got, _ := cmd.Flags().GetString("vertical-alignment"); got != "middle" {
t.Errorf("vertical-alignment = %q, want rewritten to %q", got, "middle")
}
if !*prevCalled {
t.Error("framework PreRunE must keep running first")
}
// Typo: error with suggestion, value untouched.
cmd, _ = newCmd()
cmd.Flags().Set("vertical-alignment", "botom")
err := cmd.PreRunE(cmd, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("typo should fail with *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(verr.Hint, `"bottom"`) {
t.Errorf("hint should suggest bottom for the typo, got %q", verr.Hint)
}
if got, _ := cmd.Flags().GetString("vertical-alignment"); got != "botom" {
t.Errorf("typo must not be rewritten, got %q", got)
}
// --print-schema skips enum gating (pure local introspection).
cmd, _ = newCmd()
cmd.Flags().Set("vertical-alignment", "not-a-value")
cmd.Flags().Set("print-schema", "true")
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Errorf("--print-schema must skip enum gating, got: %v", err)
}
}
// shortcutFromRegistry returns the fully wired shortcut (PostMount
// ergonomics included) as Shortcuts() exposes it to the framework.
func shortcutFromRegistry(t *testing.T, command string) common.Shortcut {
t.Helper()
for _, sc := range Shortcuts() {
if sc.Command == command {
return sc
}
}
t.Fatalf("shortcut %q not found in Shortcuts()", command)
return common.Shortcut{}
}
// TestShortcuts_FlagErgonomicsMounted verifies the ergonomics ride every
// mounted sheets command end-to-end: enum vocabulary normalizes on a real
// invocation, and unknown flags answer with the inlined valid-flag list.
func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
t.Parallel()
t.Run("enum alias normalizes through a real run", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set-style")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1:A1",
"--vertical-alignment", "center",
"--dry-run",
})
if err != nil {
t.Fatalf("center should normalize to middle and pass, got: %v", err)
}
if !strings.Contains(stdout, "middle") || strings.Contains(stdout, "center") {
t.Errorf("dry-run body should carry the normalized value, got %q", stdout)
}
})
t.Run("enum typo errors with suggestion", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set-style")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1:A1",
"--vertical-alignment", "botom",
"--dry-run",
})
ve := requireValidation(t, err, `invalid value "botom" for --vertical-alignment`)
if !strings.Contains(ve.Hint, `"bottom"`) {
t.Errorf("hint should suggest bottom, got %q", ve.Hint)
}
})
t.Run("unknown flag inlines valid flags", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cols-resize")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--cols", "A:D",
})
ve := requireValidation(t, err, `unknown flag "--cols"`)
for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
}

View File

@@ -6,7 +6,6 @@ package sheets
import (
_ "embed"
"encoding/json"
"fmt"
"sort"
"sync"
@@ -54,7 +53,7 @@ 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)
parseFlagErr = errs.NewInternalError(errs.SubtypeUnknown, "flag-schemas.json: %v", err).WithCause(err)
return
}
if idx.Flags == nil {

View File

@@ -5,6 +5,7 @@ package sheets
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
@@ -97,11 +98,22 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
// Composite-JSON shape errors (e.g. +cells-set --cells, chart
// --properties) are the highest-frequency usage-layer failure for
// sheets, and agents often burn several retries guessing the shape.
// Point them straight at --print-schema, which dumps the exact JSON
// Schema for this (command, flag) pair. The hint is always actionable:
// reaching this branch means entry[name] resolved a schema from the
// embedded index, and --print-schema reads that same index, so the
// suggested command is guaranteed to print it.
// A shallow type mismatch means the caller misremembered the overall
// container shape (the classic {"cells": ...} wrapper around what
// must be a bare 2D array), so inline a skeleton of the expected
// shape — that fixes the retry without a --print-schema round trip.
// Deeper failures keep the --print-schema pointer, which dumps the
// exact JSON Schema for this (command, flag) pair; reaching this
// branch means entry[name] resolved a schema from the embedded
// index, so the suggested command is guaranteed to print it.
var tm *typeMismatchError
if errors.As(vErr, &tm) && pathDepth(tm.path) <= skeletonPathDepthLimit {
if sk := schemaSkeleton(&schema, skeletonMaxDepth); sk != "" {
return sheetsValidationForFlag(name,
"--%s: %s; expected shape: %s (run `lark-cli sheets %s --print-schema --flag-name %s` for the full JSON Schema)",
name, vErr.Error(), sk, command, name).WithCause(vErr)
}
}
return sheetsValidationForFlag(name,
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
name, vErr.Error(), command, name).WithCause(vErr)
@@ -243,7 +255,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if schema.Type != "" {
if !matchesJSONType(value, schema.Type) {
return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value))
return &typeMismatchError{path: path, expected: schema.Type, got: jsType(value)}
}
}
@@ -251,20 +263,20 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
// 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)
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
if schema.Maximum != nil && num > *schema.Maximum {
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum)
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
// 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)
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
if schema.MaxItems != nil && len(arr) > *schema.MaxItems {
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems)
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
@@ -279,10 +291,10 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
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 != "" {
if hint := suggestEnumForError(value, schema.Enum); hint != "" {
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
}
return fmt.Errorf("%s", msg)
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
@@ -295,7 +307,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
}
}
if !matched {
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path))
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
@@ -305,7 +317,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
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))
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
if schema.Properties != nil {
@@ -357,7 +369,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
sort.Strings(extras)
for _, key := range extras {
if schema.AdditionalProperties.Strict {
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
if schema.AdditionalProperties.Schema != nil {
child := key
@@ -388,6 +400,126 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
return nil
}
// typeMismatchError is the type-check branch of validateAgainstSchema
// as a typed error, so validateValueAgainstSchema can recognize shape
// confusion (vs. deep value errors) and inline a skeleton of the
// expected shape. Error() keeps the exact legacy wording.
type typeMismatchError struct {
path string
expected string
got string
}
func (e *typeMismatchError) Error() string {
return fmt.Sprintf("%sexpected type %q, got %q", pathPrefix(e.path), e.expected, e.got)
}
// pathDepth counts how many levels below the flag root a JSON path
// points at: "" → 0, "[0]" → 1, "[0][3]" → 2, "[0][3].value" → 3,
// "legend" → 1, "snapshot.axes" → 2. Every "[" and "." starts a new
// segment; a leading bare key (no bracket) is one segment of its own.
func pathDepth(path string) int {
depth := strings.Count(path, "[") + strings.Count(path, ".")
if path != "" && path[0] != '[' {
depth++
}
return depth
}
// Skeleton rendering bounds: a mismatch at depth ≤ 2 is container-shape
// confusion worth a skeleton; deeper mismatches are value-level and the
// full schema pointer serves better. The skeleton itself stops after
// four levels and eight keys per object so it stays one line; a wide
// object (> skeletonWideObject keys) collapses its children to type
// placeholders so every key stays visible instead of the first branch
// eating the whole line.
const (
skeletonPathDepthLimit = 2
skeletonMaxDepth = 4
skeletonMaxKeys = 8
skeletonWideObject = 2
)
// schemaSkeleton renders a compact single-line sketch of the shape a
// schema expects, e.g. [[{"value": …, "formula": "…", …}]] for
// +cells-set --cells. Required keys come first, then alphabetical,
// capped at skeletonMaxKeys with a trailing … marker. Values render as
// their type placeholder; enum strings show the first allowed value.
func schemaSkeleton(s *schemaProperty, depth int) string {
if s == nil {
return "…"
}
if len(s.OneOf) > 0 && s.Type == "" {
return schemaSkeleton(s.OneOf[0], depth)
}
switch s.Type {
case "array":
if depth <= 0 {
return "[…]"
}
return "[" + schemaSkeleton(s.Items, depth-1) + "]"
case "object":
if depth <= 0 || len(s.Properties) == 0 {
return "{…}"
}
keys := skeletonKeys(s)
childDepth := depth - 1
if len(s.Properties) > skeletonWideObject {
childDepth = 0
}
parts := make([]string, 0, len(keys)+1)
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%q: %s", k, schemaSkeleton(s.Properties[k], childDepth)))
}
if len(s.Properties) > len(keys) {
parts = append(parts, "…")
}
return "{" + strings.Join(parts, ", ") + "}"
case "string":
if len(s.Enum) > 0 {
return formatJSONValue(s.Enum[0])
}
return `"…"`
case "number", "integer":
return "0"
case "boolean":
return "false"
}
return "…"
}
// skeletonKeys picks which object keys a skeleton shows: required keys
// first (schema order), then remaining keys alphabetically, capped at
// skeletonMaxKeys.
func skeletonKeys(s *schemaProperty) []string {
keys := make([]string, 0, skeletonMaxKeys)
seen := make(map[string]struct{}, skeletonMaxKeys)
for _, k := range s.Required {
if _, ok := s.Properties[k]; !ok {
continue
}
if len(keys) == skeletonMaxKeys {
return keys
}
keys = append(keys, k)
seen[k] = struct{}{}
}
rest := make([]string, 0, len(s.Properties))
for k := range s.Properties {
if _, dup := seen[k]; !dup {
rest = append(rest, k)
}
}
sort.Strings(rest)
for _, k := range rest {
if len(keys) == skeletonMaxKeys {
break
}
keys = append(keys, k)
}
return keys
}
func matchesJSONType(value interface{}, expected string) bool {
switch expected {
case "object":
@@ -473,25 +605,48 @@ func joinFormatted(values []interface{}) string {
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.
// suggestEnumMatch returns the canonical enum entry when the user's
// value unambiguously means one — casing ("SUM" vs "sum", "True" vs
// "true") or a cross-vocabulary alias (CSS "center" for Lark's vertical
// "middle"). Callers auto-apply the result, so it must stay restricted
// to unambiguous matches (edit-distance guesses belong in
// suggestEnumForError only). Non-string values have no vocabulary
// notion. Returns "" when no unambiguous match exists.
func suggestEnumMatch(value interface{}, values []interface{}) string {
s, ok := value.(string)
if !ok {
return ""
}
lower := strings.ToLower(s)
canon := canonicalEnumValue(s, stringEnumEntries(values))
if canon == "" || canon == s { // skip exact-equal (already would have matched).
return ""
}
return canon
}
// stringEnumEntries extracts the string members of a JSON-schema enum
// list (mixed-type enums keep only their string entries).
func stringEnumEntries(values []interface{}) []string {
out := make([]string, 0, len(values))
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
}
if vs, ok := v.(string); ok {
out = append(out, vs)
}
}
return ""
return out
}
// suggestEnumForError picks the "did you mean" candidate for an enum
// error message. Unlike suggestEnumMatch (whose result is auto-applied,
// so it must stay unambiguous), this one may also draw on edit distance
// — the suggestion is only prose, the user still has to re-issue the
// value explicitly.
func suggestEnumForError(value interface{}, values []interface{}) string {
s, ok := value.(string)
if !ok {
return ""
}
return closestEnumValue(s, stringEnumEntries(values))
}
func pathPrefix(path string) string {

View File

@@ -360,6 +360,142 @@ func TestValidateInputAgainstSchema_RealEnumCaseNormalized(t *testing.T) {
}
}
// TestValidateAgainstSchema_EnumAliasNormalized pins the cross-vocabulary
// auto-fix: CSS-habit "center" for a vertical alignment unambiguously means
// Lark's "middle", so the payload is normalized in place and the call
// proceeds — same treatment as the "SUM" vs "sum" casing class.
func TestValidateAgainstSchema_EnumAliasNormalized(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"object",
"properties":{"vertical_alignment":{"type":"string","enum":["top","middle","bottom"]}}
}`)
obj := map[string]interface{}{"vertical_alignment": "center"}
if err := validateAgainstSchema(obj, schema, ""); err != nil {
t.Fatalf("center should normalize to middle and pass, got: %v", err)
}
if got := obj["vertical_alignment"]; got != "middle" {
t.Errorf("vertical_alignment = %q, want normalized to %q", got, "middle")
}
}
// TestValidateAgainstSchema_EnumTypoSuggestedNotApplied pins the auto-apply
// boundary on the error path: an edit-distance typo stays an error with a
// "did you mean" suggestion, never a silent rewrite.
func TestValidateAgainstSchema_EnumTypoSuggestedNotApplied(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"object",
"properties":{"order":{"type":"string","enum":["asc","desc"]}}
}`)
obj := map[string]interface{}{"order": "ascc"}
err := validateAgainstSchema(obj, schema, "")
if err == nil {
t.Fatal("typo must be rejected")
}
if !strings.Contains(err.Error(), `did you mean "asc"?`) {
t.Errorf("enum error should suggest asc for the typo, got %q", err.Error())
}
if got := obj["order"]; got != "ascc" {
t.Errorf("typo must not be rewritten, got %q", got)
}
}
// TestValidateValueAgainstSchema_ShapeSkeletonOnShallowTypeMismatch
// pins the highest-frequency eval failure: passing an object where
// --cells expects a 2D array must inline a skeleton of the expected
// shape (with the "value" key visible) so an agent fixes the retry
// without a --print-schema round trip.
func TestValidateValueAgainstSchema_ShapeSkeletonOnShallowTypeMismatch(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+cells-set"}
err := validateValueAgainstSchema(fv, "cells",
map[string]interface{}{"cells": []interface{}{}})
if err == nil {
t.Fatal("object where array expected must fail")
}
msg := err.Error()
for _, want := range []string{`expected type "array", got "object"`, "expected shape: [[{", `"value"`} {
if !strings.Contains(msg, want) {
t.Errorf("error should contain %q, got %q", want, msg)
}
}
// Deep value-level mismatch keeps the plain --print-schema pointer
// (a whole-shape skeleton would not address the actual problem).
deep := []interface{}{[]interface{}{
map[string]interface{}{"note": 12.5},
}}
err = validateValueAgainstSchema(fv, "cells", deep)
if err == nil {
t.Fatal("wrong type for note must fail")
}
if strings.Contains(err.Error(), "expected shape:") {
t.Errorf("deep mismatch should not inline a skeleton, got %q", err.Error())
}
if !strings.Contains(err.Error(), "--print-schema") {
t.Errorf("deep mismatch should keep the --print-schema pointer, got %q", err.Error())
}
}
func TestPathDepth(t *testing.T) {
t.Parallel()
cases := []struct {
path string
want int
}{
{"", 0},
{"[0]", 1},
{"[0][3]", 2},
{"[0][3].value", 3},
{"legend", 1},
{"snapshot.axes", 2},
}
for _, c := range cases {
if got := pathDepth(c.path); got != c.want {
t.Errorf("pathDepth(%q) = %d, want %d", c.path, got, c.want)
}
}
}
func TestSchemaSkeleton(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"array",
"items":{
"type":"array",
"items":{
"type":"object",
"properties":{
"value":{},
"formula":{"type":"string"},
"align":{"type":"string","enum":["top","middle","bottom"]},
"styles":{"type":"object","properties":{"bold":{"type":"boolean"}}}
}
}
}
}`)
got := schemaSkeleton(schema, skeletonMaxDepth)
// Wide object (>2 keys) collapses nested containers to placeholders;
// enum strings surface their first allowed value.
want := `[[{"align": "top", "formula": "…", "styles": {…}, "value": …}]]`
if got != want {
t.Errorf("skeleton = %s, want %s", got, want)
}
narrow := parseSchema(t, `{
"type":"object",
"required":["sheets"],
"properties":{"sheets":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"}}}}}
}`)
got = schemaSkeleton(narrow, skeletonMaxDepth)
// Narrow object (≤2 keys) keeps descending so the inner shape shows.
want = `{"sheets": [{"name": "…"}]}`
if got != want {
t.Errorf("narrow skeleton = %s, want %s", got, want)
}
}
// 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

View File

@@ -19,6 +19,7 @@ var commandsWithSchema = map[string]struct{}{
"+cells-set-style": {},
"+chart-create": {},
"+chart-update": {},
"+cols-resize": {},
"+cond-format-create": {},
"+cond-format-update": {},
"+dropdown-set": {},
@@ -30,6 +31,7 @@ var commandsWithSchema = map[string]struct{}{
"+pivot-create": {},
"+pivot-update": {},
"+range-sort": {},
"+rows-resize": {},
"+sparkline-create": {},
"+sparkline-update": {},
"+table-put": {},

View File

@@ -281,18 +281,18 @@ func (m mapFlagView) validateRawTypes() error {
// parse time; reject here too to keep batch/standalone parity.
f, isNum := val.(float64)
if !isNum {
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
}
if math.Trunc(f) != f {
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64))
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
}
case "float64":
if _, isNum := val.(float64); !isNum {
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
}
case "bool":
if _, isBool := val.(bool); !isBool {
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val))
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
}
}
}

View File

@@ -10,6 +10,7 @@ package sheets
import (
"context"
"encoding/json"
"errors"
"fmt"
neturl "net/url"
"strings"
@@ -44,7 +45,8 @@ func sheetsValidationCauseForFlag(name string, cause error) *errs.ValidationErro
// classification and only adds the domain's flag param.
func sheetsInputStatError(flag string, err error) error {
wrapped := common.WrapInputStatErrorTyped(err)
if v, ok := wrapped.(*errs.ValidationError); ok {
var v *errs.ValidationError
if errors.As(wrapped, &v) {
return v.WithParam(sheetsFlagParam(flag))
}
return wrapped
@@ -52,21 +54,30 @@ func sheetsInputStatError(flag string, err error) error {
// Drive media parent_type values for uploading an image into a spreadsheet.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
// synthetic token prefixed with "fake_office_" and the backend requires
// "office_sheet_file" instead.
// synthetic token prefixed with "fake_office_" (being renamed to
// "local_office_") and the backend requires "office_sheet_file" instead.
const (
sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file"
fakeOfficeTokenPrefix = "fake_office_"
localOfficeTokenPrefix = "local_office_"
)
// officeTokenPrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
var officeTokenPrefixes = []string{fakeOfficeTokenPrefix, localOfficeTokenPrefix}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken. It is the single
// place that maps a spreadsheet token to its parent_type so every image-upload
// entry point (and its dry-run preview) stays consistent.
func sheetMediaParentType(spreadsheetToken string) string {
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
return officeSheetFileParentType
for _, prefix := range officeTokenPrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return officeSheetFileParentType
}
}
return sheetImageParentType
}
@@ -440,7 +451,7 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
// buildCellStyleFromFlags reads the 11 flat style flags and returns the
// buildCellStyleFromFlags reads the 12 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{} {
@@ -451,6 +462,9 @@ func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
if v := runtime.Str("font-color"); v != "" {
style["font_color"] = v
}
if v := runtime.Str("font-family"); v != "" {
style["font_family"] = v
}
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
style["font_size"] = runtime.Float64("font-size")
}

View File

@@ -215,7 +215,8 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
if borderStyles != nil {
prototype["border_styles"] = borderStyles
}
var ops []interface{}
ops := make([]interface{}, 0, len(ranges))
var totalCells int64
for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil {
@@ -225,6 +226,13 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
if err != nil {
return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err)
}
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
return nil, err
}
totalCells += int64(rows) * int64(cols)
if err := checkBatchStampBudget(totalCells); err != nil {
return nil, err
}
cells := fillCellsMatrix(rows, cols, prototype)
ops = append(ops, map[string]interface{}{
"tool_name": "set_cell_range",
@@ -299,7 +307,7 @@ func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[str
return nil, err
}
clearType := normalizeClearType(runtime.Str("scope"))
var ops []interface{}
ops := make([]interface{}, 0, len(ranges))
for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil {
@@ -382,13 +390,10 @@ var DropdownDelete = common.Shortcut{
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
ranges, err := validateDropdownRanges(runtime)
if err != nil {
// validateDropdownRanges enforces the shared maxBatchRanges cap.
if _, err := validateDropdownRanges(runtime); err != nil {
return err
}
if len(ranges) > 100 {
return sheetsValidationForFlag("ranges", "--ranges accepts at most 100 entries; got %d", len(ranges))
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -432,7 +437,8 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
}
prototype = map[string]interface{}{"data_validation": validation}
}
var ops []interface{}
ops := make([]interface{}, 0, len(ranges))
var totalCells int64
for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil {
@@ -442,6 +448,13 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
if err != nil {
return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err)
}
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
return nil, err
}
totalCells += int64(rows) * int64(cols)
if err := checkBatchStampBudget(totalCells); err != nil {
return nil, err
}
cells := fillCellsMatrix(rows, cols, prototype)
ops = append(ops, map[string]interface{}{
"tool_name": "set_cell_range",
@@ -461,6 +474,25 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
// ─── helpers resurrected from B3 (used here + future skills) ──────────
// maxBatchRanges caps how many ranges a fan-out batch (+cells-batch-set-style /
// +cells-batch-clear / +dropdown-update / +dropdown-delete) may carry, bounding
// the number of ops materialized into one batch_update.
const maxBatchRanges = 100
// checkBatchStampBudget rejects a fan-out batch whose ranges materialize more
// than maxStampMatrixCells cells in aggregate. A batch builds every range's
// cells matrix up front, so the SUM across ranges is the real peak-memory bound
// — the per-range checkStampMatrixBudget alone can't stop many ranges from
// summing past it. totalCells is int64 to stay overflow-safe.
func checkBatchStampBudget(totalCells int64) error {
if totalCells > maxStampMatrixCells {
return sheetsValidationForFlag("ranges",
"ranges expand to %d cells total, over the %d-cell safety cap; reduce the number or size of ranges",
totalCells, maxStampMatrixCells)
}
return nil
}
// validateDropdownRanges parses --ranges, requires every entry to carry a
// sheet prefix, and returns the parsed list.
func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
@@ -490,6 +522,9 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
}
out = append(out, s)
}
if len(out) > maxBatchRanges {
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(out))
}
return out, nil
}

View File

@@ -5,6 +5,7 @@ package sheets
import (
"encoding/json"
"strings"
"testing"
)
@@ -419,6 +420,94 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
}
}
// TestBatchUpdate_PrescriptiveHints pins the recovery hints that ride on the
// highest-frequency batch failures, so an agent can repair its payload in a
// single retry without --help / --print-schema round trips.
func TestBatchUpdate_PrescriptiveHints(t *testing.T) {
t.Parallel()
cases := []struct {
name string
opsJSON string
wantMatch string
wantInHint []string
}{
{
name: "missing shortcut gets entry template",
opsJSON: `[{"input":{"range":"A1"}}]`,
wantMatch: "'shortcut' field is required",
wantInHint: []string{`{"shortcut":"+cells-set"`, `"input"`},
},
{
name: "disallowed shortcut lists the allow-list inline",
opsJSON: `[{"shortcut":"+cells-batch-set-style","input":{}}]`,
wantMatch: "not allowed in +batch-update",
wantInHint: []string{"allowed shortcuts:", "+cells-set-style", "+range-copy"},
},
{
name: "translator failure lists full key contract",
opsJSON: `[{"shortcut":"+dim-insert","input":{"sheet_name":"s"}}]`,
wantMatch: "--position is required",
wantInHint: []string{"+dim-insert input keys:", "sheet_id|sheet_name (choose one)", "position (required)", "count (required)", "inherit_style"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", tc.opsJSON,
"--yes",
"--dry-run",
})
ve := requireValidation(t, err, tc.wantMatch)
for _, want := range tc.wantInHint {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
}
}
// TestTranslateBatchOperations_OverLimitSplitHint pins the split
// prescription on the 100-entry cap: the hint must say how many batches
// the caller should re-issue.
func TestTranslateBatchOperations_OverLimitSplitHint(t *testing.T) {
t.Parallel()
ops := make([]interface{}, 185)
for i := range ops {
ops[i] = map[string]interface{}{"shortcut": "+cells-set", "input": map[string]interface{}{}}
}
_, err := translateBatchOperations(ops, "shtcnX")
ve := requireValidation(t, err, "accepts at most 100 entries; got 185")
for _, want := range []string{"2 separate +batch-update calls", "at most 100 entries each"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
}
// TestSubOpInputContract pins the contract line derivation from flag-defs:
// reserved spreadsheet locators are omitted, the sheet selector collapses
// to a choose-one, and required flags are marked.
func TestSubOpInputContract(t *testing.T) {
t.Parallel()
got := subOpInputContract("+dim-insert")
for _, want := range []string{"sheet_id|sheet_name (choose one)", "position (required)", "count (required)", "inherit_style"} {
if !strings.Contains(got, want) {
t.Errorf("contract should contain %q, got %q", want, got)
}
}
for _, banned := range []string{"url", "spreadsheet_token", "dry_run"} {
if strings.Contains(got, banned) {
t.Errorf("contract must not expose %q, got %q", banned, got)
}
}
if got := subOpInputContract("+no-such-shortcut"); got != "" {
t.Errorf("unknown shortcut should yield empty contract, got %q", got)
}
}
// 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).
@@ -447,7 +536,7 @@ 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}}]`,
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","range":"1:3","height":30}}]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")

View File

@@ -0,0 +1,106 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_changeset ─────────────────────────────────────────────
//
// +changeset-get wraps the get_changeset read tool: fetch the raw changeset
// (the list of edit actions) between two CS revisions of a spreadsheet, so a
// human or reviewing agent can verify whether an AI edit actually fulfilled
// the user's request.
//
// - --start-revision is the "before" baseline (required, >= 1).
// - --end-revision is optional; when omitted it defaults to the latest
// revision, returning every changeset from start up to now.
// - The version gap is capped at 20 (end - start + 1 <= 20); the same cap
// is enforced server-side (sheet-facade-agg maxChangesetRevGap).
const changesetMaxRevGap = 20
// ChangesetGet fetches the raw changesets between two spreadsheet versions.
var ChangesetGet = common.Shortcut{
Service: "sheets",
Command: "+changeset-get",
Description: "Fetch the raw changeset (edit actions) between two versions, to review whether an AI edit fulfilled the request.",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+changeset-get"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, _, err := changesetRevisions(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
input, _ := changesetInput(runtime, token)
return invokeToolDryRun(token, ToolKindRead, "get_changeset", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
input, err := changesetInput(runtime, token)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_changeset", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Pass only --start-revision to diff against the latest version; add --end-revision to bound the range.",
"The version gap is capped at 20 revisions (end - start + 1 <= 20).",
},
}
// changesetRevisions reads and validates the start / end revision flags.
// end <= 0 means "not provided" (default to latest, resolved server-side); a
// provided end must be >= start and within the 20-revision gap.
func changesetRevisions(runtime flagView) (start int, end int, err error) {
start = runtime.Int("start-revision")
end = runtime.Int("end-revision")
if start < 1 {
return 0, 0, sheetsValidationForFlag("start-revision", "--start-revision must be >= 1")
}
if end > 0 {
if end < start {
return 0, 0, sheetsValidationForFlag("end-revision", "--end-revision (%d) must be >= --start-revision (%d)", end, start)
}
if end-start+1 > changesetMaxRevGap {
return 0, 0, sheetsValidationForFlag("end-revision", "version gap exceeds limit %d (start=%d, end=%d)", changesetMaxRevGap, start, end)
}
}
return start, end, nil
}
// changesetInput builds the get_changeset tool input. end_revision is only
// sent when explicitly provided; otherwise the server defaults to latest.
func changesetInput(runtime flagView, token string) (map[string]interface{}, error) {
start, end, err := changesetRevisions(runtime)
if err != nil {
return nil, err
}
input := map[string]interface{}{
"excel_id": token,
"start_revision": start,
}
if end > 0 {
input["end_revision"] = end
}
return input, nil
}

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// TestChangesetGet_DryRun locks the get_changeset tool input: --end-revision
// is only sent when explicitly provided, otherwise the server defaults to the
// latest revision.
func TestChangesetGet_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
wantInput map[string]interface{}
}{
{
name: "start + end bounded range",
args: []string{"--url", testURL, "--start-revision", "120", "--end-revision", "135"},
wantInput: map[string]interface{}{
"excel_id": testToken,
"start_revision": float64(120),
"end_revision": float64(135),
},
},
{
name: "start only → end omitted (server defaults to latest)",
args: []string{"--url", testURL, "--start-revision", "120"},
wantInput: map[string]interface{}{
"excel_id": testToken,
"start_revision": float64(120),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChangesetGet, tt.args)
got := decodeToolInput(t, body, "get_changeset")
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestChangesetGet_Validation covers the client-side revision guards, which
// mirror the server cap (sheet-facade-agg maxChangesetRevGap = 20).
func TestChangesetGet_Validation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
wantSub string
}{
{
name: "start-revision must be >= 1",
args: []string{"--url", testURL, "--start-revision", "0"},
wantSub: "start-revision must be >= 1",
},
{
name: "end before start rejected",
args: []string{"--url", testURL, "--start-revision", "100", "--end-revision", "50"},
wantSub: "end-revision",
},
{
name: "gap over 20 rejected",
args: []string{"--url", testURL, "--start-revision", "1", "--end-revision", "30"},
wantSub: "version gap exceeds limit",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, ChangesetGet, append(c.args, "--dry-run"))
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), c.wantSub) {
t.Errorf("expected %q; got=%s|%s|%v", c.wantSub, stdout, stderr, err)
}
})
}
}

View File

@@ -0,0 +1,167 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_formula_verify ───────────────────────────────────────
//
// Wraps verify_formula (read): scan formulas + cell error states across one
// or more sub-sheets and aggregate Excel errors (#REF! / #DIV/0! / #VALUE! /
// #NAME? / #NULL! / #NUM! / #N/A) plus compile failures (formula_errors)
// into a recalc.py-shaped JSON status report. The contract is the single
// AI self-check entry point for the R10 "write → verify zero-error"
// invariant — see canonical-spec/references/lark_sheet_formula_verify/.
// FormulaVerify wraps verify_formula. Sheet selection is optional (both
// --sheet-id and --sheet-name are repeatable); when omitted, the tool scans
// every visible sub-sheet's current_region.
var FormulaVerify = common.Shortcut{
Service: "sheets",
Command: "+formula-verify",
Description: "Scan formulas / cell errors and return a recalc.py-shaped status report (success / errors_found / partial).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+formula-verify"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
if err := validateFormulaVerifySheetSelector(runtime); err != nil {
return err
}
return validateFormulaVerifyLimits(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
return invokeToolDryRun(token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "verify_formula", formulaVerifyInput(runtime, token))
if err != nil {
return err
}
runtime.Out(out, nil)
if runtime.Bool("exit-on-error") {
return formulaVerifyExitOnError(out)
}
return nil
},
}
// validateFormulaVerifySheetSelector enforces XOR-like guarantees on the
// two multi-value selectors: at most one of --sheet-id / --sheet-name may be
// non-empty (passing both is the high-frequency reflex confusion when the
// caller cargo-cults the single-sheet shortcut signature). Both empty is the
// documented "scan every visible sub-sheet" path. Control-char checks reuse
// requireSheetSelector's logic on each item.
func validateFormulaVerifySheetSelector(runtime *common.RuntimeContext) error {
ids := nonEmptySliceItems(runtime.StrSlice("sheet-id"))
names := nonEmptySliceItems(runtime.StrSlice("sheet-name"))
if len(ids) > 0 && len(names) > 0 {
return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive; pick one selector to identify sub-sheets").
WithParams(
sheetsInvalidParam("sheet-id", "mutually exclusive"),
sheetsInvalidParam("sheet-name", "mutually exclusive"),
)
}
for _, id := range ids {
if err := requireSheetSelector(id, ""); err != nil {
return err
}
}
for _, name := range names {
if err := requireSheetSelector("", name); err != nil {
return err
}
}
return nil
}
// validateFormulaVerifyLimits rejects non-positive caps so a misplaced 0 or
// negative flag value can't silently degrade the scan (the server-side
// default would otherwise mask the typo).
func validateFormulaVerifyLimits(runtime *common.RuntimeContext) error {
if runtime.Changed("max-locations") && runtime.Int("max-locations") <= 0 {
return sheetsValidationForFlag("max-locations", "--max-locations must be > 0")
}
return nil
}
// nonEmptySliceItems trims and drops blanks from a repeated-flag value so
// `--sheet-id ""` doesn't masquerade as a real entry.
func nonEmptySliceItems(in []string) []string {
out := make([]string, 0, len(in))
for _, v := range in {
if trimmed := strings.TrimSpace(v); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
// formulaVerifyInput builds the verify_formula tool input map from CLI flags.
// excel_id is required; everything else is optional per the schema.
func formulaVerifyInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
input := map[string]interface{}{
"excel_id": token,
}
if ids := nonEmptySliceItems(runtime.StrSlice("sheet-id")); len(ids) > 0 {
input["sheet_ids"] = ids
} else if names := nonEmptySliceItems(runtime.StrSlice("sheet-name")); len(names) > 0 {
// The verify_formula schema only declares sheet_ids; the facade
// accepts sheet_names as a parallel optional field so name-based
// selection works without forcing the caller to pre-resolve. Mirrors
// how the other read shortcuts pack both fields via
// sheetSelectorForToolInput.
input["sheet_names"] = names
}
if ranges := nonEmptySliceItems(runtime.StrSlice("range")); len(ranges) > 0 {
input["ranges"] = ranges
}
if runtime.Changed("max-locations") {
input["max_locations_per_error"] = runtime.Int("max-locations")
}
return input
}
// formulaVerifyExitOnError converts a verify_formula status into a non-zero
// CLI exit when the caller passed --exit-on-error. status="errors_found"
// is the only failure mode for this flag: "partial" means truncated but the
// scanned slice is clean, and "success" is obviously clean. A missing /
// unknown status is treated as a typed internal error because the tool's
// schema guarantees the field and we don't want a silent zero-exit.
func formulaVerifyExitOnError(out interface{}) error {
m, ok := out.(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"verify_formula: missing status field in tool output")
}
status, _ := m["status"].(string)
switch status {
case "success", "partial":
return nil
case "errors_found":
total, _ := util.ToFloat64(m["total_errors"])
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"verify_formula: %d formula error(s) detected; resolve and re-run", int(total)).
WithHint("inspect error_summary[*] / compile_errors[*] in the JSON output, fix or wrap with IFERROR, then re-run +formula-verify until status=success")
default:
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"verify_formula: unexpected status %q", status)
}
}

View File

@@ -0,0 +1,213 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
// TestFormulaVerify_DryRun pins the wire shape verify_formula sends for the
// common input combinations: no selector (workbook-wide scan), explicit
// sheet_ids, explicit ranges, and the optional max_locations_per_error
// field. The test exercises the One-OpenAPI body
// directly so the schema field names stay locked to the canonical
// tool-schemas.json verify_formula node.
func TestFormulaVerify_DryRun(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
wantInput map[string]interface{}
}{
{
name: "no selector — workbook-wide scan defaults",
args: []string{"--url", testURL},
wantInput: map[string]interface{}{
"excel_id": testToken,
},
},
{
name: "sheet_ids multi via repeat",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--sheet-id", testSheetID2},
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_ids": []interface{}{testSheetID, testSheetID2},
},
},
{
name: "sheet_names multi via comma",
args: []string{"--url", testURL, "--sheet-name", "Sheet1,Sheet2"},
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_names": []interface{}{"Sheet1", "Sheet2"},
},
},
{
name: "ranges + max_locations",
args: []string{
"--url", testURL,
"--range", "A1:Z200",
"--range", "AA1:AZ100",
"--max-locations", "5",
},
wantInput: map[string]interface{}{
"excel_id": testToken,
"ranges": []interface{}{"A1:Z200", "AA1:AZ100"},
"max_locations_per_error": float64(5),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, FormulaVerify, tt.args)
got := decodeToolInput(t, body, "verify_formula")
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestFormulaVerify_DryRunInvokeReadPath confirms the request hits
// invoke_read (read scope) and not invoke_write — a scope mismatch here would
// surface as a 403 from the gateway.
func TestFormulaVerify_DryRunInvokeReadPath(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, FormulaVerify, []string{"--url", testURL})
if len(calls) == 0 {
t.Fatalf("dry-run produced no api calls")
}
call, _ := calls[0].(map[string]interface{})
url, _ := call["url"].(string)
if !strings.HasSuffix(url, "/tools/invoke_read") {
t.Errorf("verify_formula must hit invoke_read; got url=%q", url)
}
if want := "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read"; url != want {
t.Errorf("url = %q, want %q", url, want)
}
}
// TestFormulaVerify_RejectsBothSelectors locks the "at most one selector"
// rule on the two multi-value flags. Both empty is the documented
// workbook-wide scan path, so we only reject the both-supplied case.
func TestFormulaVerify_RejectsBothSelectors(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, FormulaVerify, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--sheet-name", "Sheet1",
"--dry-run",
})
ve := requireValidation(t, err, "mutually exclusive")
gotParams := map[string]bool{}
for _, p := range ve.Params {
gotParams[p.Name] = true
}
if !gotParams["--sheet-id"] || !gotParams["--sheet-name"] {
t.Errorf("params = %#v, want both --sheet-id and --sheet-name flagged", ve.Params)
}
}
// TestFormulaVerify_RejectsNonPositiveLimits guards against typos like
// `--max-locations 0`, which would otherwise be silently swallowed by the
// "explicit value but unset" comparison in the input builder.
func TestFormulaVerify_RejectsNonPositiveLimits(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
want string
}{
{
name: "max-locations=0",
args: []string{"--url", testURL, "--max-locations", "0"},
want: "--max-locations must be > 0",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, FormulaVerify, append(c.args, "--dry-run"))
requireValidation(t, err, c.want)
})
}
}
// TestFormulaVerifyExitOnError_StatusMatrix locks the --exit-on-error
// contract: success/partial → no error; errors_found → typed validation
// error with SubtypeFailedPrecondition; missing or unknown status →
// typed internal error so a silent zero-exit can never happen.
func TestFormulaVerifyExitOnError_StatusMatrix(t *testing.T) {
t.Parallel()
t.Run("success returns no error", func(t *testing.T) {
t.Parallel()
if err := formulaVerifyExitOnError(map[string]interface{}{"status": "success"}); err != nil {
t.Fatalf("success path returned err: %v", err)
}
})
t.Run("partial returns no error", func(t *testing.T) {
t.Parallel()
if err := formulaVerifyExitOnError(map[string]interface{}{"status": "partial", "has_more": true}); err != nil {
t.Fatalf("partial path returned err: %v", err)
}
})
t.Run("errors_found yields failed_precondition with count", func(t *testing.T) {
t.Parallel()
err := formulaVerifyExitOnError(map[string]interface{}{
"status": "errors_found",
"total_errors": float64(7),
})
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if !strings.Contains(ve.Message, "7 formula error") {
t.Errorf("message %q must surface the error count", ve.Message)
}
if ve.Hint == "" {
t.Errorf("hint must be set so AI agents know to re-run after fixes")
}
})
t.Run("unknown status maps to internal/invalid_response", func(t *testing.T) {
t.Parallel()
err := formulaVerifyExitOnError(map[string]interface{}{"status": "weird"})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype)
}
})
t.Run("non-object output maps to internal/invalid_response", func(t *testing.T) {
t.Parallel()
err := formulaVerifyExitOnError("oops")
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("category/subtype = %q/%q, want internal/invalid_response", p.Category, p.Subtype)
}
})
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_history (BE-1: +history-list) ─────────────────────────
//
// Wraps the facade-agg `history_list` tool (read) behind the One-OpenAPI
// invoke_read endpoint. The tool returns a sheet's version history. The
// facade-agg tool already performs the response transform (minor_histories
// trim / id → history_version_id / 4-field projection / RFC3339 create_time),
// so the CLI passes the tool output straight through and does NOT re-implement
// the transform client-side.
//
// History is workbook-level (no sheet selector), mirroring +workbook-info:
// the only locator is --url / --spreadsheet-token (XOR), with --token accepted
// as a parse-time alias for --spreadsheet-token via the shared PostMount hook.
//
// Flags are declared inline here rather than via flagsFor(): the generated
// flag_defs_gen.go / data/flag-defs.json are synced from sheet-skill-spec
// (BE-3) and must not be hand-edited, so this hand-written shortcut owns its
// own flag set. The two locator flags match +workbook-info's shape exactly.
// historyLocatorFlags is the --url / --spreadsheet-token XOR locator pair
// shared by the three history shortcuts. Mirrors +workbook-info's flag-defs
// entry; XOR is enforced in Validate via parseSpreadsheetRef, not by Required.
func historyLocatorFlags() []common.Flag {
return []common.Flag{
{Name: "url", Type: "string", Desc: "Spreadsheet locator (a /sheets/ or /wiki/ URL)."},
{Name: "spreadsheet-token", Type: "string", Desc: "Spreadsheet locator (raw spreadsheet token)."},
}
}
// HistoryList wraps the history_list tool: list a spreadsheet's history
// versions. Each item carries history_version_id / create_time / action /
// all_block_revision (projected server-side). An empty sheet yields an empty
// list and exit 0.
//
// Backward pagination: --end-version (optional int) maps to the tool's
// `end_version` parameter. Omit on the first call to fetch the latest page.
// On subsequent pages pass the previous response's next_end_version as
// --end-version. The tool returns next_end_version + has_more only when
// more history exists; both fields are absent at the earliest page.
var HistoryList = common.Shortcut{
Service: "sheets",
Command: "+history-list",
Description: "List a spreadsheet's edit history versions (history_version_id, create_time, action, all_block_revision).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: append(historyLocatorFlags(),
common.Flag{Name: "end-version", Type: "int", Desc: "Max version to query (descending pagination). Omit on the first call; pass the previous response's next_end_version on subsequent pages."},
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := resolveSpreadsheetToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
return invokeToolDryRun(token, ToolKindRead, "history_list", historyListInput(runtime, token))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "history_list", historyListInput(runtime, token))
if err != nil {
return err
}
// Pass the tool output through verbatim — facade-agg already shaped it.
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Capture a history_version_id from the result to feed +history-revert.",
"For older history, capture next_end_version from the response and pass it as --end-version on the next call (omitted by the server when the earliest page is reached).",
},
}
// historyListInput composes the history_list tool input. --end-version is
// optional: include it only when explicitly set so the server treats absence
// as "first page (latest)".
func historyListInput(runtime *common.RuntimeContext, token string) map[string]interface{} {
in := map[string]interface{}{"excel_id": token}
if runtime.Changed("end-version") {
in["end_version"] = runtime.Int("end-version")
}
return in
}

View File

@@ -0,0 +1,197 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_history (BE-2: +history-revert / +history-revert-status) ──
//
// Two thin callTool wrappers over the facade-agg history tools:
// - +history-revert → history_revert (write) — async revert
// - +history-revert-status → history_revert_status (read) — poll outcome
//
// Both target a single history version via --history-version-id (the id
// surfaced by +history-list). Revert is asynchronous: it returns a receipt /
// transaction id that +history-revert-status then polls, distinguishing
// in-progress / success / failure from the tool output (passed through
// verbatim — no client-side shaping).
//
// ⚠️ Backend state: the facade-agg history_revert / history_revert_status
// tools are registered but their downstream RPC wiring is a DEFERRED
// follow-up; today they return a "not wired yet" guard error from the gateway,
// which surfaces here as a normal tool error. These CLI shortcuts are correct
// thin wrappers and will work end-to-end once the backend follow-up lands —
// this is NOT a CLI blocker. See self_check.md.
//
// Flags are declared inline (historyLocatorFlags + history-version-id) rather
// than via flagsFor(), because flag_defs_gen.go / data/flag-defs.json are
// synced from sheet-skill-spec (BE-3) and must not be hand-edited.
// historyVersionIDFlag is the target-version selector shared by +history-revert.
// Required at the cli surface (cobra MarkFlagRequired): a missing value yields
// cobra's standard "required flag(s) \"history-version-id\" not set" message
// before Validate runs. We still trim + reject control-chars in Validate to
// reject empty strings ("--history-version-id "" "), which cobra accepts.
func historyVersionIDFlag() common.Flag {
return common.Flag{
Name: "history-version-id",
Type: "string",
Required: true,
Desc: "History version to act on (from +history-list).",
}
}
func historyRevertFlags() []common.Flag {
return append(historyLocatorFlags(), historyVersionIDFlag())
}
// validateHistoryVersionID enforces the required, control-char-clean
// --history-version-id. Returns the trimmed value so callers reuse it.
func validateHistoryVersionID(runtime *common.RuntimeContext) (string, error) {
id := strings.TrimSpace(runtime.Str("history-version-id"))
if id == "" {
return "", sheetsValidationForFlag("history-version-id", "--history-version-id is required")
}
return id, nil
}
func historyRevertInput(token, versionID string) map[string]interface{} {
return map[string]interface{}{
"excel_id": token,
"history_version_id": versionID,
}
}
// transactionIDFlag is the async-revert receipt selector used by
// +history-revert-status: the transaction_id returned by +history-revert (NOT a
// history version id — the facade-agg status tool keys on transaction_id).
// Required at the cli surface (cobra MarkFlagRequired) — same gating model as
// historyVersionIDFlag. Validate still trims + rejects empty/control-char
// values to catch the case where cobra accepts --transaction-id with an
// empty-string value.
func transactionIDFlag() common.Flag {
return common.Flag{
Name: "transaction-id",
Type: "string",
Required: true,
Desc: "Async revert transaction id (from +history-revert).",
}
}
func historyRevertStatusFlags() []common.Flag {
return append(historyLocatorFlags(), transactionIDFlag())
}
// validateTransactionID enforces the required, trimmed --transaction-id and
// returns it for reuse.
func validateTransactionID(runtime *common.RuntimeContext) (string, error) {
id := strings.TrimSpace(runtime.Str("transaction-id"))
if id == "" {
return "", sheetsValidationForFlag("transaction-id", "--transaction-id is required")
}
return id, nil
}
func historyRevertStatusInput(token, transactionID string) map[string]interface{} {
return map[string]interface{}{
"excel_id": token,
"transaction_id": transactionID,
}
}
// HistoryRevert wraps the history_revert tool (write): asynchronously revert a
// spreadsheet to the given history version. --history-version-id is required
// at the cli surface (cobra MarkFlagRequired); a missing flag fails before
// Validate runs with cobra's standard "required flag(s)" error (which the
// dispatcher classifies as a typed *errs.ValidationError, exit 2). We still
// trim + reject empty / control-char values in Validate to catch the
// case where cobra accepts --history-version-id with an empty-string value.
var HistoryRevert = common.Shortcut{
Service: "sheets",
Command: "+history-revert",
Description: "Revert a spreadsheet to a given history version (asynchronous; poll with +history-revert-status).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: historyRevertFlags(),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, err := validateHistoryVersionID(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
versionID := strings.TrimSpace(runtime.Str("history-version-id"))
return invokeToolDryRun(token, ToolKindWrite, "history_revert", historyRevertInput(token, versionID))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
versionID, err := validateHistoryVersionID(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "history_revert", historyRevertInput(token, versionID))
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"Revert is asynchronous — pass the returned id to +history-revert-status to track in-progress / success / failure.",
},
}
// HistoryRevertStatus wraps the history_revert_status tool (read): poll the
// outcome of a prior +history-revert. The tool output distinguishes
// in-progress / success / failure and is passed through verbatim.
var HistoryRevertStatus = common.Shortcut{
Service: "sheets",
Command: "+history-revert-status",
Description: "Poll the status of a history revert (in-progress / success / failure).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: historyRevertStatusFlags(),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
}
_, err := validateTransactionID(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
txnID := strings.TrimSpace(runtime.Str("transaction-id"))
return invokeToolDryRun(token, ToolKindRead, "history_revert_status", historyRevertStatusInput(token, txnID))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
txnID, err := validateTransactionID(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "history_revert_status", historyRevertStatusInput(token, txnID))
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}

View File

@@ -0,0 +1,167 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
// TestHistoryShortcuts_DryRun asserts each history shortcut targets the right
// facade-agg tool, routes through the correct read/write invoke endpoint, and
// builds the expected tool input (excel_id always; history_version_id for the
// revert pair).
func TestHistoryShortcuts_DryRun(t *testing.T) {
t.Parallel()
const versionID = "histVER123"
const txnID = "txn-abc-123"
tests := []struct {
name string
sc common.Shortcut
args []string
toolName string
wantPath string // invoke_read | invoke_write suffix
wantInput map[string]interface{}
}{
{
name: "+history-list via --url",
sc: HistoryList,
args: []string{"--url", testURL},
toolName: "history_list",
wantPath: "invoke_read",
wantInput: map[string]interface{}{
"excel_id": testToken,
},
},
{
name: "+history-list via --spreadsheet-token",
sc: HistoryList,
args: []string{"--spreadsheet-token", testToken},
toolName: "history_list",
wantPath: "invoke_read",
wantInput: map[string]interface{}{
"excel_id": testToken,
},
},
{
name: "+history-list paginates with --end-version",
sc: HistoryList,
args: []string{"--url", testURL, "--end-version", "12345"},
toolName: "history_list",
wantPath: "invoke_read",
wantInput: map[string]interface{}{
"excel_id": testToken,
"end_version": float64(12345), // post-JSON-unmarshal numeric type
},
},
{
name: "+history-revert routes to invoke_write with version id",
sc: HistoryRevert,
args: []string{"--url", testURL, "--history-version-id", versionID},
toolName: "history_revert",
wantPath: "invoke_write",
wantInput: map[string]interface{}{
"excel_id": testToken,
"history_version_id": versionID,
},
},
{
name: "+history-revert-status routes to invoke_read with transaction id",
sc: HistoryRevertStatus,
args: []string{"--url", testURL, "--transaction-id", txnID},
toolName: "history_revert_status",
wantPath: "invoke_read",
wantInput: map[string]interface{}{
"excel_id": testToken,
"transaction_id": txnID,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
callURL := dryRunFirstCallURL(t, tt.sc, tt.args)
if !containsSuffix(callURL, tt.wantPath) {
t.Errorf("invoke url = %q, want suffix %q", callURL, tt.wantPath)
}
body := parseDryRunBody(t, tt.sc, tt.args)
got := decodeToolInput(t, body, tt.toolName)
assertInputEquals(t, got, tt.wantInput)
})
}
}
// TestHistoryRevert_MissingRequiredFlag asserts each shortcut rejects a
// missing required selector before any request is sent, with two distinct
// gates by design:
//
// - +history-revert: --history-version-id is cobra-required (Required=true
// in the flag def → MarkFlagRequired). cobra refuses the call before
// Validate runs with a plain "required flag(s)" error; the cmd dispatcher
// classifies it as a typed *errs.ValidationError (invalid_argument, exit 2).
// The test rig invokes the shortcut via cmd.Execute and observes the raw
// cobra error directly (no dispatcher wrap), so we assert the cobra text
// contract instead of the typed envelope.
//
// - +history-revert-status: --transaction-id is cobra-optional;
// requiredness is enforced inside Validate so we still get a typed,
// flag-tagged *errs.ValidationError with Param="--transaction-id".
func TestHistoryRevert_MissingRequiredFlag(t *testing.T) {
t.Parallel()
t.Run(HistoryRevert.Command, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, HistoryRevert, []string{"--url", testURL})
if err == nil {
t.Fatalf("%s: expected error for missing --history-version-id", HistoryRevert.Command)
}
msg := err.Error()
if !strings.Contains(msg, "required flag(s)") || !strings.Contains(msg, "history-version-id") {
t.Fatalf("%s: cobra error = %q, want substrings 'required flag(s)' and 'history-version-id'", HistoryRevert.Command, msg)
}
})
t.Run(HistoryRevertStatus.Command, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, HistoryRevertStatus, []string{"--url", testURL})
if err == nil {
t.Fatalf("%s: expected error for missing --transaction-id", HistoryRevertStatus.Command)
}
msg := err.Error()
if !strings.Contains(msg, "required flag(s)") || !strings.Contains(msg, "transaction-id") {
t.Fatalf("%s: cobra error = %q, want substrings 'required flag(s)' and 'transaction-id'", HistoryRevertStatus.Command, msg)
}
})
}
// dryRunFirstCallURL runs the shortcut in --dry-run and returns the first
// api call's url, so tests can assert read vs. write endpoint routing.
func dryRunFirstCallURL(t *testing.T, sc common.Shortcut, args []string) string {
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, 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{})
url, _ := call["url"].(string)
return url
}
func containsSuffix(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@@ -5,6 +5,8 @@ package sheets
import (
"context"
"fmt"
"sort"
"strings"
"github.com/larksuite/cli/errs"
@@ -208,77 +210,81 @@ func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMerg
return input, nil
}
// resize_range exposes two CLI shortcuts:
// resize_range exposes two CLI shortcuts, each with two input forms:
//
// +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.
// +rows-resize / +cols-resize — set row heights / column widths.
//
// Uniform form: --range + --height/--width <px>; the pixel mode is implied
// so --type can be omitted (or set to `pixel` — equivalent). Non-pixel
// modes go through --type standard / --type auto (rows only) and cannot be
// combined with the pixel flag. --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.
//
// Map form: --heights / --widths carries a JSON object of per-row/column
// sizes ({"A": 100, "C:E": 120, "G": "standard"}) and fans out into one
// atomic batch_update of resize_range ops — different sizes for many
// rows/columns in a single CLI call, no +batch-update needed. Mutually
// exclusive with --range/--height/--width/--type, and not accepted as a
// +batch-update sub-op (nested batch_update is unsupported upstream).
//
// Wire shape: resize_height / resize_width carries { type, value? }, e.g.
// { "type": "pixel", "value": 30 } or { "type": "standard" }.
//
// Units are pixels. Column widths in Excel character units (openpyxl /
// xlsxwriter mental model, px ≈ chars × 8 + 16) are a real agent trap, so
// widths below minSaneColumnWidthPx are rejected with a conversion hint.
// RowsResize wraps resize_range for row heights. --type auto enables
// auto-fit (rows only); --type pixel requires --size.
// RowsResize wraps resize_range for row heights. Pass --range + --height
// <px> for a uniform pixel height, --heights '{"1":50,"2:20":30}' for
// per-row heights, or --type standard/auto for non-pixel modes.
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\").",
Description: "Resize rows in pixels: --range + --height <px> for one uniform height, --heights '{\"1\":50,\"2:20\":30,\"21\":\"auto\"}' for per-row heights in one atomic call, or --type standard/auto (--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 := resolveSpreadsheetTokenExec(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
},
DryRun: resizeDryRun("row"),
Execute: resizeExecute("row"),
}
// ColsResize wraps resize_range for column widths. Column widths do not
// support auto-fit — --type only accepts pixel / standard.
// ColsResize wraps resize_range for column widths. Pass --range + --width
// <px> for a uniform pixel width, --widths '{"A":100,"C:E":120}' for
// per-column widths, or --type standard for the default width. Column
// widths do not support auto-fit — --type does not accept auto.
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).",
Description: "Resize columns in pixels (NOT Excel char units): --range + --width <px> for one uniform width, --widths '{\"A\":100,\"C:E\":120}' for per-column widths in one atomic call, or --type standard to reset (--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 {
DryRun: resizeDryRun("column"),
Execute: resizeExecute("column"),
}
// resizeDryRun / resizeExecute route a resize shortcut through resizeToolCall
// so the uniform form hits resize_range and the map form hits batch_update
// with identical inputs in preview and execution.
func resizeDryRun(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return 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 {
toolName, input, _ := resizeToolCall(runtime, token, sheetID, sheetName, dimension)
return invokeToolDryRun(token, ToolKindWrite, toolName, input)
}
}
func resizeExecute(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) error {
return func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
@@ -287,22 +293,21 @@ var ColsResize = common.Shortcut{
if err != nil {
return err
}
input, err := resizeInput(runtime, token, sheetID, sheetName, "column")
toolName, input, err := resizeToolCall(runtime, token, sheetID, sheetName, dimension)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", input)
out, err := callTool(ctx, runtime, token, ToolKindWrite, toolName, 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.
// validateViaResize wires the standalone Validate to resizeToolCall so both
// forms (uniform + map) are fully validated before execution.
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)
@@ -311,17 +316,82 @@ func validateViaResize(dimension string) func(ctx context.Context, runtime *comm
}
sheetID := strings.TrimSpace(runtime.Str("sheet-id"))
sheetName := strings.TrimSpace(runtime.Str("sheet-name"))
_, err = resizeInput(runtime, token, sheetID, sheetName, dimension)
_, _, err = resizeToolCall(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"
// resizeToolCall picks the input form: map form (--heights/--widths) builds a
// batch_update of resize_range ops; uniform form builds a single resize_range
// input. Returns the tool name to invoke alongside its input.
func resizeToolCall(runtime flagView, token, sheetID, sheetName, dimension string) (string, map[string]interface{}, error) {
if runtime.Changed(sizeMapFlag(dimension)) {
input, err := resizeMapInput(runtime, token, sheetID, sheetName, dimension)
return "batch_update", input, err
}
return ""
input, err := resizeInput(runtime, token, sheetID, sheetName, dimension)
return "resize_range", input, err
}
// nonPixelTypes lists the --type values a given dimension accepts (rows also
// accept auto; columns only accept standard). Used to shape the hint printed
// when --type is missing or invalid.
func nonPixelTypes(dimension string) string {
if dimension == "row" {
return "standard / auto"
}
return "standard"
}
// pixelFlag maps a dimension to its pixel-value flag name (--height for rows,
// --width for cols). The wire block always emits "pixel" as the mode; the
// per-dimension flag name is just the surface knob.
func pixelFlag(dimension string) string {
if dimension == "row" {
return "height"
}
return "width"
}
// sizeMapFlag maps a dimension to its map-form flag name (--heights for rows,
// --widths for cols).
func sizeMapFlag(dimension string) string {
return pixelFlag(dimension) + "s"
}
// rejectResizeMapInBatch blocks the map form inside +batch-update sub-ops:
// it expands into its own batch_update and nesting batch_update is
// unsupported upstream. Called by the batch dispatch closures only — the
// standalone path routes the map form through resizeMapInput instead.
func rejectResizeMapInBatch(fv flagView, dimension string) error {
mapFlag := sizeMapFlag(dimension)
if !fv.Changed(mapFlag) {
return nil
}
return sheetsValidationForFlag(mapFlag,
"%q is not supported inside +batch-update (it expands into its own atomic batch); call %s --%s standalone, or give each sub-op the single-range form (range + %s/type)",
mapFlag, commandForDimension(dimension), mapFlag, pixelFlag(dimension))
}
// minSaneColumnWidthPx is the floor below which a column width almost
// certainly means the caller thought in Excel character units (openpyxl /
// xlsxwriter widths run 8-30 chars) instead of pixels. 10px columns are
// unusable; real pixel spacer columns start around 20px.
const minSaneColumnWidthPx = 20
// checkPixelSize validates a pixel value for one dimension. label names the
// offending input in the error ("--width" for the uniform flag, "--widths
// key \"A\"" for a map entry).
func checkPixelSize(dimension, flagName, label string, px int) error {
if px <= 0 {
return sheetsValidationForFlag(flagName, "%s must be > 0", label)
}
if dimension == "column" && px < minSaneColumnWidthPx {
return sheetsValidationForFlag(flagName,
"%s = %dpx is below %dpx and looks like an Excel character-unit width — column widths here are pixels (px ≈ chars × 8 + 16, so %d chars ≈ %dpx)",
label, px, minSaneColumnWidthPx, px, px*8+16)
}
return nil
}
// commandForDimension returns the shortcut command name a given dimension
@@ -339,6 +409,11 @@ func commandForDimension(dimension string) string {
// 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.
//
// Surface: pixel size goes through --height / --width (dimension-specific).
// --type is optional when the pixel flag is present (defaults to "pixel");
// explicit --type pixel is accepted and equivalent. --type standard / auto
// select non-pixel modes and cannot be combined with the pixel flag.
func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
@@ -361,29 +436,42 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string)
if !strings.Contains(rangeStr, ":") {
rangeStr = rangeStr + ":" + rangeStr
}
sizeFlag := pixelFlag(dimension)
hasSize := runtime.Changed(sizeFlag)
typ := strings.TrimSpace(runtime.Str("type"))
if typ == "" {
return nil, sheetsValidationForFlag("type", "--type is required (pixel / standard%s)", autoSuffix(dimension))
hasType := typ != ""
if !hasSize && !hasType {
return nil, common.ValidationErrorf("give --%s <px> for a pixel size, or --type %s", sizeFlag, nonPixelTypes(dimension)).WithParams(sheetsInvalidParam(sizeFlag, "required"), sheetsInvalidParam("type", "required"))
}
if dimension == "column" && typ == "auto" {
if hasSize && hasType && typ != "pixel" {
return nil, common.ValidationErrorf("--%s cannot be combined with --type %s", sizeFlag, typ).WithParams(sheetsInvalidParam(sizeFlag, "mutually exclusive"), sheetsInvalidParam("type", "mutually exclusive"))
}
if hasType && dimension == "column" && typ == "auto" {
return nil, sheetsValidationForFlag("type", "--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.ValidationErrorf("--type pixel requires --size <px>").WithParams(sheetsInvalidParam("type", "required"), sheetsInvalidParam("size", "required"))
if hasType && typ == "pixel" && !hasSize {
return nil, common.ValidationErrorf("--type pixel requires --%s <px>", sizeFlag).WithParams(sheetsInvalidParam("type", "required"), sheetsInvalidParam(sizeFlag, "required"))
}
if typ != "pixel" && hasSize {
return nil, common.ValidationErrorf("--size is only valid with --type pixel").WithParams(sheetsInvalidParam("size", "mutually exclusive"), sheetsInvalidParam("type", "mutually exclusive"))
sizeBlock := map[string]interface{}{}
if hasSize {
px := runtime.Int(sizeFlag)
if err := checkPixelSize(dimension, sizeFlag, "--"+sizeFlag, px); err != nil {
return nil, err
}
sizeBlock["type"] = "pixel"
sizeBlock["value"] = px
} else {
sizeBlock["type"] = typ
}
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 {
@@ -392,6 +480,122 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string)
return input, nil
}
// resizeMapInput builds the batch_update input for the map form: every
// --heights/--widths entry becomes one resize_range op inside a single atomic
// batch. Keys are single rows/columns ("5" / "A") or closed ranges ("2:8" /
// "C:E") matching the command's dimension; values are positive pixel ints or
// the non-pixel mode strings ("standard", and "auto" for rows). Ops are
// sorted by start position so dry-run output and execution order are
// deterministic (JSON object order is not preserved by Go maps).
func resizeMapInput(runtime flagView, token, sheetID, sheetName, dimension string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
mapFlag := sizeMapFlag(dimension)
for _, other := range []string{"range", pixelFlag(dimension), "type"} {
if runtime.Changed(other) {
return nil, common.ValidationErrorf("--%s is a self-contained map; do not combine it with --%s", mapFlag, other).WithParams(sheetsInvalidParam(mapFlag, "mutually exclusive"), sheetsInvalidParam(other, "mutually exclusive"))
}
}
parsed, err := parseJSONFlag(runtime, mapFlag)
if err != nil {
return nil, err
}
entries, ok := parsed.(map[string]interface{})
if !ok || parsed == nil {
return nil, sheetsValidationForFlag(mapFlag, "--%s must be a JSON object like {\"%s\": 100}", mapFlag, exampleMapKey(dimension))
}
if len(entries) == 0 {
return nil, sheetsValidationForFlag(mapFlag, "--%s must contain at least one entry", mapFlag)
}
type resizeOp struct {
start int
input map[string]interface{}
}
ops := make([]resizeOp, 0, len(entries))
seen := make(map[string]string, len(entries)) // normalized range → original key
for key, raw := range entries {
parsedDim, startIdx, _, err := parseA1Range(key)
if err != nil {
return nil, sheetsValidationForFlag(mapFlag, "--%s key %q: %v", mapFlag, key, err)
}
if parsedDim != dimension {
want := "row numbers (e.g. \"2:10\")"
if dimension == "column" {
want = "column letters (e.g. \"A:E\")"
}
return nil, sheetsValidationForFlag(mapFlag, "--%s key %q is a %s range; %s expects %s", mapFlag, key, parsedDim, commandForDimension(dimension), want)
}
normalized := strings.TrimSpace(key)
if !strings.Contains(normalized, ":") {
normalized = normalized + ":" + normalized
}
if prev, dup := seen[normalized]; dup {
return nil, sheetsValidationForFlag(mapFlag, "--%s keys %q and %q target the same range %s; merge them into one entry", mapFlag, prev, key, normalized)
}
seen[normalized] = key
sizeBlock := map[string]interface{}{}
switch v := raw.(type) {
case float64:
px := int(v)
if float64(px) != v {
return nil, sheetsValidationForFlag(mapFlag, "--%s[%q] must be an integer pixel value, got %v", mapFlag, key, v)
}
if err := checkPixelSize(dimension, mapFlag, fmt.Sprintf("--%s[%q]", mapFlag, key), px); err != nil {
return nil, err
}
sizeBlock["type"] = "pixel"
sizeBlock["value"] = px
case string:
mode := strings.TrimSpace(v)
if mode == "auto" && dimension == "column" {
return nil, sheetsValidationForFlag(mapFlag, "--%s[%q]: \"auto\" is rows-only (column widths do not support auto-fit); estimate a pixel width instead (px ≈ chars × 8 + 16)", mapFlag, key)
}
if mode != "standard" && !(mode == "auto" && dimension == "row") {
return nil, sheetsValidationForFlag(mapFlag, "--%s[%q] = %q is invalid; use a pixel integer or %s", mapFlag, key, v, nonPixelTypes(dimension))
}
sizeBlock["type"] = mode
default:
return nil, sheetsValidationForFlag(mapFlag, "--%s[%q] must be a pixel integer or a mode string (%s), got %s", mapFlag, key, nonPixelTypes(dimension), jsonTypeName(raw))
}
opInput := map[string]interface{}{
"excel_id": token,
"range": normalized,
}
sheetSelectorForToolInput(opInput, sheetID, sheetName)
if dimension == "row" {
opInput["resize_height"] = sizeBlock
} else {
opInput["resize_width"] = sizeBlock
}
ops = append(ops, resizeOp{start: startIdx, input: opInput})
}
sort.Slice(ops, func(i, j int) bool { return ops[i].start < ops[j].start })
operations := make([]interface{}, 0, len(ops))
for _, op := range ops {
operations = append(operations, map[string]interface{}{
"tool_name": "resize_range",
"input": op.input,
})
}
return map[string]interface{}{
"excel_id": token,
"operations": operations,
}, nil
}
// exampleMapKey renders a dimension-appropriate sample key for error hints.
func exampleMapKey(dimension string) string {
if dimension == "row" {
return "2:10"
}
return "A"
}
// ─── transform_range (4 shortcuts) ────────────────────────────────────
//
// move / copy take --source-range + --target-range (+ optional cross-sheet

View File

@@ -113,9 +113,9 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) {
},
},
{
name: "+rows-resize --range 1:5 pixel 200",
name: "+rows-resize --range 1:5 --height 200",
sc: RowsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel", "--size", "200"},
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--height", "200"},
toolName: "resize_range",
wantInput: map[string]interface{}{
"excel_id": testToken,
@@ -138,7 +138,7 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) {
},
},
{
name: "+cols-resize --range B:D standard",
name: "+cols-resize --range B:D --type standard",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "B:D", "--type", "standard"},
toolName: "resize_range",
@@ -152,9 +152,22 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) {
},
},
{
name: "+cols-resize --range A:C pixel 120",
name: "+cols-resize --range A:C --width 120",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel", "--size", "120"},
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--width", "120"},
toolName: "resize_range",
wantInput: map[string]interface{}{
"range": "A:C",
"resize_width": map[string]interface{}{
"type": "pixel",
"value": float64(120),
},
},
},
{
name: "+cols-resize --type pixel with --width 120 (explicit == implicit)",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel", "--width", "120"},
toolName: "resize_range",
wantInput: map[string]interface{}{
"range": "A:C",
@@ -296,6 +309,163 @@ func TestRangeSort_RejectsMalformedKeys(t *testing.T) {
}
}
// TestResize_MapForm covers the --widths/--heights map form: entries fan out
// into one atomic batch_update of resize_range ops, sorted by start position
// regardless of JSON key order.
func TestResize_MapForm(t *testing.T) {
t.Parallel()
t.Run("+cols-resize --widths mixes pixels, ranges and standard", func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ColsResize, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--widths", `{"G": "standard", "A": 100, "C:E": 120}`,
})
input := decodeToolInput(t, body, "batch_update")
wantOps := []interface{}{
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
"excel_id": testToken, "sheet_id": testSheetID, "range": "A:A",
"resize_width": map[string]interface{}{"type": "pixel", "value": float64(100)},
}},
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
"excel_id": testToken, "sheet_id": testSheetID, "range": "C:E",
"resize_width": map[string]interface{}{"type": "pixel", "value": float64(120)},
}},
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
"excel_id": testToken, "sheet_id": testSheetID, "range": "G:G",
"resize_width": map[string]interface{}{"type": "standard"},
}},
}
assertInputEquals(t, input, map[string]interface{}{
"excel_id": testToken,
"operations": wantOps,
})
})
t.Run("+rows-resize --heights mixes pixels, auto and standard", func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, RowsResize, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--heights", `{"21": "auto", "1": 50, "2:20": 30}`,
})
input := decodeToolInput(t, body, "batch_update")
wantOps := []interface{}{
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
"excel_id": testToken, "sheet_id": testSheetID, "range": "1:1",
"resize_height": map[string]interface{}{"type": "pixel", "value": float64(50)},
}},
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
"excel_id": testToken, "sheet_id": testSheetID, "range": "2:20",
"resize_height": map[string]interface{}{"type": "pixel", "value": float64(30)},
}},
map[string]interface{}{"tool_name": "resize_range", "input": map[string]interface{}{
"excel_id": testToken, "sheet_id": testSheetID, "range": "21:21",
"resize_height": map[string]interface{}{"type": "auto"},
}},
}
assertInputEquals(t, input, map[string]interface{}{
"excel_id": testToken,
"operations": wantOps,
})
})
}
// TestResize_MapFormGuards covers map-form validation: exclusivity with the
// uniform flags, key/value shape errors, the char-unit width floor, and the
// +batch-update nesting rejection.
func TestResize_MapFormGuards(t *testing.T) {
t.Parallel()
cases := []struct {
name string
sc common.Shortcut
args []string
want string
}{
{
name: "--widths rejects --range",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 100}`, "--range", "A:C"},
want: "--widths is a self-contained map; do not combine it with --range",
},
{
name: "--widths rejects --width",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 100}`, "--width", "120"},
want: "--widths is a self-contained map; do not combine it with --width",
},
{
name: "--heights rejects --type",
sc: RowsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"1": 50}`, "--type", "auto"},
want: "--heights is a self-contained map; do not combine it with --type",
},
{
name: "--widths empty object",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{}`},
want: "must contain at least one entry",
},
{
name: "--widths row key on cols command",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"2:8": 100}`},
want: "+cols-resize expects column letters",
},
{
name: "--heights column key on rows command",
sc: RowsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"A": 50}`},
want: "+rows-resize expects row numbers",
},
{
name: "--widths duplicate keys A and A:A",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 100, "A:A": 120}`},
want: "target the same range A:A",
},
{
name: "--widths char-unit width rejected with conversion hint",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": 10}`},
want: "looks like an Excel character-unit width",
},
{
// The embedded schema (enum ["standard"]) rejects "auto" before the
// Go-level rows-only hint; the error steers to --print-schema whose
// description explains columns don't support auto.
name: "--widths rejects auto (rows-only) via schema",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A": "auto"}`},
want: "does not match any of oneOf alternatives",
},
{
name: "--heights rejects unknown mode string via schema",
sc: RowsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"1": "fit"}`},
want: "does not match any of oneOf alternatives",
},
{
name: "--heights rejects boolean value via schema",
sc: RowsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--heights", `{"1": true}`},
want: "does not match any of oneOf alternatives",
},
{
name: "--widths bad key syntax",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--widths", `{"A1:B2": 100}`},
want: "expected pure digits (row number) or letters",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
requireValidation(t, err, tt.want)
})
}
}
func TestResize_TypeAndSizeGuards(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -305,22 +475,58 @@ func TestResize_TypeAndSizeGuards(t *testing.T) {
want string
}{
{
name: "+rows-resize --type pixel without --size",
name: "+rows-resize missing both --height and --type",
sc: RowsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel"},
want: "--type pixel requires --size",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5"},
want: "give --height <px> for a pixel size, or --type standard / auto",
},
{
name: "+rows-resize --type standard with --size",
name: "+cols-resize missing both --width and --type",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C"},
want: "give --width <px> for a pixel size, or --type standard",
},
{
name: "+rows-resize --height rejects --type standard",
sc: RowsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "standard", "--size", "30"},
want: "--size is only valid with --type pixel",
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--height", "30", "--type", "standard"},
want: "--height cannot be combined with --type standard",
},
{
name: "+cols-resize --width rejects --type standard",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--width", "120", "--type", "standard"},
want: "--width cannot be combined with --type standard",
},
{
name: "+rows-resize --type pixel without --height",
sc: RowsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel"},
want: "--type pixel requires --height",
},
{
name: "+cols-resize --type pixel without --width",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel"},
want: "--type pixel requires --width",
},
{
name: "+rows-resize --height must be positive",
sc: RowsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--height", "0"},
want: "--height must be > 0",
},
{
name: "+cols-resize --width below 20px rejected with char-unit hint",
sc: ColsResize,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--width", "12"},
want: "looks like an Excel character-unit width",
},
{
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"
want: "auto", // cobra Enum gate kicks first with "valid values are: standard"
},
{
name: "+rows-resize given column range",

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_revision_get ───────────────────────────────────────────
//
// RevisionGet is a read-only derivative over get_workbook_structure that
// projects out only the document revision (version number). The backend
// surfaces `revision` on every read/write tool response, so this shortcut
// needs no dedicated backend tool — it issues the lightest existing read
// (no range, just the workbook token) and narrows the payload to the single
// field callers want.
//
// The revision is the anchor for recover / undo. Callers that have just run a
// write already have it in that write's response; +revision-get is the
// explicit, zero-side-effect way to fetch the current value on its own.
var RevisionGet = common.Shortcut{
Service: "sheets",
Command: "+revision-get",
Description: "Get the spreadsheet's current document revision (version number).",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+revision-get"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := resolveSpreadsheetToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
return invokeToolDryRun(token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
"excel_id": token,
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
"excel_id": token,
})
if err != nil {
return err
}
rev, err := projectRevision(out)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"revision": rev}, nil)
return nil
},
Tips: []string{
"The revision is the version anchor for recover / undo; every read and write tool response already carries it.",
},
}
// projectRevision narrows a get_workbook_structure response to its `revision`
// field. An absent revision means the backend predates revision injection on
// read responses; surface that as an explicit error rather than emitting a
// silent null.
func projectRevision(out interface{}) (interface{}, error) {
obj, ok := out.(map[string]interface{})
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"get_workbook_structure returned non-object output")
}
rev, ok := obj["revision"]
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"get_workbook_structure did not return a revision (backend may not support it yet)")
}
return rev, nil
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import "testing"
func TestRevisionGetProjectRevision(t *testing.T) {
t.Parallel()
t.Run("extracts revision from a workbook-structure object", func(t *testing.T) {
out := map[string]interface{}{
"revision": float64(60),
"sheets": []interface{}{map[string]interface{}{"sheet_id": "Nh34WX"}},
}
got, err := projectRevision(out)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != float64(60) {
t.Errorf("revision = %v, want 60", got)
}
})
t.Run("errors when revision is absent", func(t *testing.T) {
out := map[string]interface{}{"sheets": []interface{}{}}
if _, err := projectRevision(out); err == nil {
t.Error("expected an error when revision is missing, got nil")
}
})
t.Run("errors on a non-object output", func(t *testing.T) {
if _, err := projectRevision("not-an-object"); err == nil {
t.Error("expected an error for non-object output, got nil")
}
})
}

View File

@@ -483,11 +483,11 @@ func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) (map[
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")
return "", 0, 0, fmt.Errorf("range is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
parts := strings.Split(s, ":")
if len(parts) > 2 {
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element")
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
dim1, idx1, err := parseA1Position(parts[0])
if err != nil {
@@ -501,10 +501,10 @@ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error)
return "", 0, 0, err
}
if dim1 != dim2 {
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range")
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
if idx2 < idx1 {
return "", 0, 0, fmt.Errorf("end position is before start")
return "", 0, 0, fmt.Errorf("end position is before start") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
return dim1, idx1, idx2, nil
}
@@ -515,7 +515,7 @@ func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error)
func parseA1Position(s string) (dimension string, idx int, err error) {
s = strings.TrimSpace(s)
if s == "" {
return "", 0, fmt.Errorf("position is empty")
return "", 0, fmt.Errorf("position is empty") //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
isDigits := true
isLetters := true
@@ -530,14 +530,14 @@ func parseA1Position(s string) (dimension string, idx int, err error) {
if isDigits {
n, _ := strconv.Atoi(s)
if n <= 0 {
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s)
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
return "row", n - 1, nil
}
if isLetters {
return "column", letterToColumnIndex(s), nil
}
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s)
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s) //nolint:forbidigo // intermediate error; callers wrap it into a typed flag validation error
}
// columnIndexToLetter converts a 0-based column index to the spreadsheet

View File

@@ -317,17 +317,52 @@ func (in *tableSheetIn) normalize(idx int) (tableSheetSpec, error) {
// compare against the canonical set.
for k := range in.Dtypes {
if !seenCol[k] {
return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: dtypes references unknown column %q", idx, in.Name, k)
return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: dtypes references unknown column %q", idx, in.Name, k).
WithHint("%s", columnKeyHint("dtypes", k, in.Columns))
}
}
for k := range in.Formats {
if !seenCol[k] {
return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: formats references unknown column %q", idx, in.Name, k)
return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: formats references unknown column %q", idx, in.Name, k).
WithHint("%s", columnKeyHint("formats", k, in.Columns))
}
}
return spec, nil
}
// columnKeyHint explains a dtypes/formats key that matched no column. The
// dominant failure is Excel habit — keying by column letter (A/B/AA) instead
// of the column name — so call that out explicitly; either way, inline the
// declared column names so the retry needs no second look at the payload.
func columnKeyHint(field, key string, columns []string) string {
shown := columns
const maxShown = 12
suffix := ""
if len(shown) > maxShown {
shown = shown[:maxShown]
suffix = ", …"
}
list := `"` + strings.Join(shown, `", "`) + `"` + suffix
if isColumnLetterKey(key) {
return fmt.Sprintf("%s keys must be column names from `columns`, not A1-style column letters; this sheet's columns: %s", field, list)
}
return fmt.Sprintf("%s keys must exactly match a name in `columns`: %s", field, list)
}
// isColumnLetterKey reports whether key looks like an A1-style column letter
// (A, B, AA, …) rather than a real column name.
func isColumnLetterKey(key string) bool {
if key == "" || len(key) > 3 {
return false
}
for _, r := range key {
if r < 'A' || r > 'Z' {
return false
}
}
return true
}
func (p *tablePayload) validate() error {
if len(p.Sheets) == 0 {
return common.ValidationErrorf("--sheets: must contain at least one sheet")
@@ -382,6 +417,32 @@ func (p *tablePayload) validate() error {
return common.ValidationErrorf("--sheets[%d] %q: mode %q is invalid (want \"overwrite\" or \"append\")", i, s.Name, s.Mode)
}
}
return p.checkCellBudget()
}
// maxTablePutCells bounds how many cells a single +table-put / +workbook-create
// write may materialize. Unlike the fan-out stamp cap (maxStampMatrixCells),
// these cells come from the caller's own --sheets/--values payload rather than a
// range blow-up, so this is a generous OOM guardrail, not a usability limit:
// buildSheetMatrix builds the whole rows×cols matrix of per-cell maps in memory
// before slicing it into tablePutMaxCellsPerWrite-sized writes, so an unbounded
// payload (2.6M cells ≈ 900MB heap, doubled again by json.Marshal) OOMs the
// process before the first write leaves.
const maxTablePutCells = 1_000_000
// checkCellBudget rejects a payload whose total materialized cell count across
// all sheets exceeds maxTablePutCells. Counted in int64 to stay overflow-safe on
// pathological row/column counts.
func (p *tablePayload) checkCellBudget() error {
var total int64
for i := range p.Sheets {
total += int64(len(p.Sheets[i].Rows)) * int64(len(p.Sheets[i].Columns))
}
if total > maxTablePutCells {
return common.ValidationErrorf(
"--sheets/--values cover %d cells total, over the %d-cell safety cap; split the write across smaller payloads",
total, maxTablePutCells)
}
return nil
}
@@ -558,6 +619,12 @@ var excelEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
// parser still rejects it cleanly.
func isoDateToSerial(s string) (int, error) {
s = strings.TrimSpace(s)
if s == "" {
// Empty cells in a date-typed column are the classic header/total-row
// clash with the column-wide dtype declaration; name the three ways
// out so the caller does not have to guess what "bad format" means.
return 0, fmt.Errorf("date column has an empty cell — drop the empty rows, fill real yyyy-mm-dd dates, or declare the column dtype as object (text)") //nolint:forbidigo // intermediate error; callers wrap it into a typed --sheets/--values validation error with row/column context
}
if i := strings.Index(s, "T"); i > 0 {
s = s[:i]
}
@@ -643,7 +710,8 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
if err != nil {
return nil, err
}
if err := applyWorkbookCreateStylesToMatrix(matrix, styles, col0, baseRow, fmt.Sprintf("--styles for sheet %q", s.Name)); err != nil {
matrix, err = applyWorkbookCreateStylesToMatrix(matrix, styles, col0, baseRow, fmt.Sprintf("--styles for sheet %q", s.Name))
if err != nil {
return nil, err
}
@@ -655,11 +723,15 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
}, nil
}
// styles can pad the matrix wider than the declared columns (cell_styles on
// blank cells past the data extent), so the written width comes from the
// padded matrix, not ncols.
writeCols := len(matrix[0])
startCol := columnIndexToLetter(col0)
endCol := columnIndexToLetter(col0 + ncols - 1)
endCol := columnIndexToLetter(col0 + writeCols - 1)
allowOverwrite := s.AllowOverwrite == nil || *s.AllowOverwrite
rowsPerBatch := tablePutMaxCellsPerWrite / ncols
rowsPerBatch := tablePutMaxCellsPerWrite / writeCols
if rowsPerBatch < 1 {
rowsPerBatch = 1
}
@@ -693,7 +765,7 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
"sheet_id": sheetID,
"range": fmt.Sprintf("%s%d:%s%d", startCol, baseRow+1, endCol, baseRow+len(matrix)),
"data_rows": len(s.Rows),
"columns": ncols,
"columns": writeCols,
"writes": writes,
"mode": writeModeName(s),
}, nil
@@ -785,7 +857,7 @@ func writeTypedSheets(ctx context.Context, runtime *common.RuntimeContext, token
s := &payload.Sheets[i]
sheetID, ok := byName[s.Name]
if !ok {
rows, cols := sheetCreateDims(s)
rows, cols := sheetCreateDims(s, styles.styleFor(i))
sheetID, err = createSheet(ctx, runtime, token, s.Name, rows, cols)
if err != nil {
return written, fmt.Errorf("creating sheet %q failed: %w", s.Name, err) //nolint:forbidigo // intermediate error; surfaced as a partial_success message string via tablePutPartial, not a typed final error
@@ -864,10 +936,12 @@ func createSheet(ctx context.Context, runtime *common.RuntimeContext, token, nam
// sheetCreateDims sizes a to-be-created sheet to the spec's write range so the
// follow-up set_cell_range can't exceed sheet bounds. It accounts for the
// start_cell offset and the optional header row. The backend's 20×200 defaults
// are kept as floors (ordinary small tables are created exactly as before) and
// its hard limits (200 cols, 50000 rows) as ceilings.
func sheetCreateDims(s *tableSheetSpec) (rows, cols int) {
// start_cell offset, the optional header row, and any --styles extent (so a
// cell_styles / merge / resize op past the data still fits the grid). The
// backend's 20×200 defaults are kept as floors (ordinary small tables are
// created exactly as before) and its hard limits (200 cols, 50000 rows) as
// ceilings.
func sheetCreateDims(s *tableSheetSpec, styles *workbookCreateStylePayload) (rows, cols int) {
_, col0, row0, _ := sheetAnchor(s)
cols = col0 + len(s.Columns)
rows = row0 + len(s.Rows)
@@ -882,6 +956,19 @@ func sheetCreateDims(s *tableSheetSpec) (rows, cols int) {
if headerOn(s) || (s.Mode == "append" && s.Header == nil) {
rows++
}
// --styles can reach past the data (cell_styles on blank cells get padded
// into the matrix and written; merges / resizes run as separate ops). Size
// the grid to cover them too. workbookCreateStyleDimensions returns the
// extent relative to the anchor, so add the anchor offset back.
if styles != nil {
styleRows, styleCols := workbookCreateStyleDimensions(styles, col0, row0)
if col0+styleCols > cols {
cols = col0 + styleCols
}
if row0+styleRows > rows {
rows = row0 + styleRows
}
}
if cols < 20 {
cols = 20
}
@@ -979,8 +1066,6 @@ func tablePutDryRun(runtime *common.RuntimeContext) *common.DryRunAPI {
for i := range payload.Sheets {
s := &payload.Sheets[i]
matrix, _ := buildSheetMatrix(s, headerOn(s))
desc := fmt.Sprintf("write sheet %q (%d data rows × %d cols, mode=%s) via set_cell_range",
s.Name, len(s.Rows), len(s.Columns), writeModeName(s))
rng := tablePutFullRange(s, len(matrix))
if s.Mode == "append" {
rng = "<append below existing data>"
@@ -988,10 +1073,23 @@ func tablePutDryRun(runtime *common.RuntimeContext) *common.DryRunAPI {
// cell_styles are merged into the matrix only for overwrite mode,
// where the anchor row is known statically; append's base row is
// resolved at execute time, so the preview leaves the matrix bare
// (the merges / sizes ops below still render).
// (the merges / sizes ops below still render). Padding can widen /
// lengthen the matrix past the data, so recompute the range from the
// padded dims to match what Execute writes.
_, col0, row0, _ := sheetAnchor(s)
_ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
matrix, _ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
if len(matrix) > 0 {
rng = fmt.Sprintf("%s%d:%s%d",
columnIndexToLetter(col0), row0+1,
columnIndexToLetter(col0+len(matrix[0])-1), row0+len(matrix))
}
}
writeCols := len(s.Columns)
if len(matrix) > 0 {
writeCols = len(matrix[0])
}
desc := fmt.Sprintf("write sheet %q (%d data rows × %d cols, mode=%s) via set_cell_range",
s.Name, len(s.Rows), writeCols, writeModeName(s))
input := map[string]interface{}{
"excel_id": token,
"sheet_name": s.Name,

View File

@@ -54,6 +54,67 @@ func TestTablePut_IsoDateToSerial(t *testing.T) {
}
}
// TestTablePut_EmptyDatePrescription pins the empty-cell branch: the error
// must name the three ways out (drop rows / fill dates / object dtype)
// instead of the generic "must be ISO" parse failure.
func TestTablePut_EmptyDatePrescription(t *testing.T) {
t.Parallel()
for _, in := range []string{"", " "} {
_, err := isoDateToSerial(in)
if err == nil {
t.Fatalf("isoDateToSerial(%q) should fail", in)
}
for _, want := range []string{"empty cell", "object (text)"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("isoDateToSerial(%q) error should contain %q, got %q", in, want, err.Error())
}
}
}
}
// TestTablePut_ColumnKeyHint pins the dtypes/formats unknown-column hint:
// A1-style letter keys get the Excel-habit callout, and the declared column
// names ride inline either way.
func TestTablePut_ColumnKeyHint(t *testing.T) {
t.Parallel()
cols := []string{"姓名", "出生日期"}
got := columnKeyHint("dtypes", "A", cols)
for _, want := range []string{"not A1-style column letters", `"姓名", "出生日期"`} {
if !strings.Contains(got, want) {
t.Errorf("letter-key hint should contain %q, got %q", want, got)
}
}
got = columnKeyHint("formats", "出生 日期", cols)
if strings.Contains(got, "A1-style") {
t.Errorf("non-letter key must not get the letter callout, got %q", got)
}
if !strings.Contains(got, `"姓名", "出生日期"`) {
t.Errorf("hint should inline column names, got %q", got)
}
many := make([]string, 20)
for i := range many {
many[i] = fmt.Sprintf("col%02d", i)
}
got = columnKeyHint("dtypes", "X", many)
if !strings.Contains(got, ", …") || strings.Contains(got, "col19") {
t.Errorf("hint should truncate long column lists, got %q", got)
}
}
func TestIsColumnLetterKey(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"A": true, "Z": true, "AA": true, "ABC": true,
"": false, "ABCD": false, "a": false, "A1": false, "姓名": false,
}
for in, want := range cases {
if got := isColumnLetterKey(in); got != want {
t.Errorf("isColumnLetterKey(%q) = %v, want %v", in, got, want)
}
}
}
func TestTablePut_BuildTypedCell(t *testing.T) {
t.Parallel()
@@ -537,14 +598,15 @@ func TestTablePut_SheetCreateDims(t *testing.T) {
cases := []struct {
name string
spec tableSheetSpec
styles *workbookCreateStylePayload
wantRows, wantCols int
}{
{"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, 200, 20},
{"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, 200, 37},
{"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, 501, 20},
{"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, 200, 42},
{"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, 500, 20},
{"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, 200, 200},
{"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, nil, 200, 20},
{"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, nil, 200, 37},
{"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, nil, 501, 20},
{"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, nil, 200, 42},
{"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, nil, 500, 20},
{"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, nil, 200, 200},
// Default headerOn() is false for append mode, but writeSheetData forces
// a header when append hits an empty sheet with no explicit Header
// choice (so column names aren't lost). sheetCreateDims runs only on
@@ -552,14 +614,22 @@ func TestTablePut_SheetCreateDims(t *testing.T) {
// match: append + Header=nil ⇒ +1 row. Otherwise an append-near-50000
// payload would be created one row short.
{"append on new sheet sizes for the forced header row (49999 data rows + 1 header = 50000)",
tableSheetSpec{Mode: "append", Columns: cols(3), Rows: rows(49999)}, 50000, 20},
tableSheetSpec{Mode: "append", Columns: cols(3), Rows: rows(49999)}, nil, 50000, 20},
{"append + Header=false (explicit) does NOT add the forced header row",
tableSheetSpec{Mode: "append", Header: bp(false), Columns: cols(3), Rows: rows(50)}, 200, 20},
tableSheetSpec{Mode: "append", Header: bp(false), Columns: cols(3), Rows: rows(50)}, nil, 200, 20},
// --styles reaching past the data grows the grid so a cell_styles op on a
// blank cell (or a merge / resize) still fits after the create.
{"styles past the data grow the grid",
tableSheetSpec{Columns: cols(3), Rows: rows(5)},
&workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{{Range: "A1:Z400"}}}, 400, 26},
{"styles inside the data don't shrink the grid",
tableSheetSpec{Columns: cols(3), Rows: rows(5)},
&workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{{Range: "A1:B2"}}}, 200, 20},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotRows, gotCols := sheetCreateDims(&tt.spec)
gotRows, gotCols := sheetCreateDims(&tt.spec, tt.styles)
if gotRows != tt.wantRows || gotCols != tt.wantCols {
t.Errorf("sheetCreateDims = (%d rows, %d cols), want (%d, %d)", gotRows, gotCols, tt.wantRows, tt.wantCols)
}

View File

@@ -123,6 +123,26 @@ func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, e
if strings.TrimSpace(runtime.Str("title")) == "" {
return nil, common.ValidationErrorf("--title is required")
}
// --type bitable 建一张空白多维表格子表operation=create_bitable默认 sheet 为普通
// 电子表格子表。bitable 子表内容编辑走 lark-base 命令row-count/col-count 不适用。
sheetType := strings.TrimSpace(runtime.Str("type"))
if sheetType == "" {
sheetType = "sheet"
}
if sheetType != "sheet" && sheetType != "bitable" {
return nil, common.ValidationErrorf("--type must be 'sheet' or 'bitable'")
}
if sheetType == "bitable" {
input := map[string]interface{}{
"excel_id": token,
"operation": "create_bitable",
"sheet_name": strings.TrimSpace(runtime.Str("title")),
}
if runtime.Changed("index") {
input["target_index"] = runtime.Int("index")
}
return input, nil
}
if n := runtime.Int("row-count"); n < 0 || n > 50000 {
return nil, common.ValidationErrorf("--row-count must be between 0 and 50000")
}
@@ -634,16 +654,26 @@ var WorkbookCreate = common.Shortcut{
s := &payload.Sheets[i]
matrix, _ := buildSheetMatrix(s, headerOn(s))
_, col0, row0, _ := sheetAnchor(s)
_ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
matrix, _ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
// Padding can widen / lengthen the matrix past the data, so build the
// range from the padded dims to match what Execute writes.
rng := tablePutFullRange(s, len(matrix))
writeCols := len(s.Columns)
if len(matrix) > 0 {
writeCols = len(matrix[0])
rng = fmt.Sprintf("%s%d:%s%d",
columnIndexToLetter(col0), row0+1,
columnIndexToLetter(col0+writeCols-1), row0+len(matrix))
}
input := map[string]interface{}{
"excel_id": "<new-token>",
"sheet_name": s.Name,
"range": tablePutFullRange(s, len(matrix)),
"range": rng,
"cells": matrix,
}
wireBody, _ := buildToolBody("set_cell_range", input)
dry.POST("/open-apis/sheet_ai/v2/spreadsheets/<new-token>/tools/invoke_write").
Desc(fmt.Sprintf("write sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), len(s.Columns))).
Desc(fmt.Sprintf("write sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), writeCols)).
Body(wireBody)
appendWorkbookCreateVisualOpsDryRun(dry, "<new-token>", "", s.Name, sheetStyles.styleFor(i))
}
@@ -836,13 +866,19 @@ func buildValuesPayload(runtime flagView, sheetStyles *workbookCreateSheetStyles
cols[i] = tableColumnSpec{Name: fmt.Sprintf("col%d", i+1)} // type-less
}
noHeader := false
return &tablePayload{Sheets: []tableSheetSpec{{
payload := &tablePayload{Sheets: []tableSheetSpec{{
Name: valuesSheetName,
Mode: "overwrite",
Header: &noHeader,
Columns: cols,
Rows: rows,
}}}, nil
}}}
// --values bypasses tablePayload.validate(), so enforce the cell budget here
// too — otherwise a giant --values array materializes unbounded.
if err := payload.checkCellBudget(); err != nil {
return nil, err
}
return payload, nil
}
// parseValuesRows decodes --values (JSON 2D array, with @file/stdin already
@@ -1139,10 +1175,14 @@ func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]work
}
return nil, common.ValidationErrorf("%s[%d].range %q must use %s", path, i, rangeStr, want)
}
typeHint := "pixel/standard"
if dimension == "row" {
typeHint = "pixel/standard/auto"
}
resizeType, _ := op["type"].(string)
resizeType = strings.TrimSpace(resizeType)
if resizeType == "" {
return nil, common.ValidationErrorf("%s[%d].type is required (pixel/standard%s)", path, i, autoSuffix(dimension))
return nil, common.ValidationErrorf("%s[%d].type is required (%s)", path, i, typeHint)
}
if dimension == "column" && resizeType == "auto" {
return nil, common.ValidationErrorf("%s[%d].type auto is rows-only", path, i)
@@ -1150,7 +1190,7 @@ func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]work
switch resizeType {
case "pixel", "standard", "auto":
default:
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want pixel/standard%s)", path, i, resizeType, autoSuffix(dimension))
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want %s)", path, i, resizeType, typeHint)
}
size := 0
if raw, ok := op["size"]; ok {
@@ -1246,7 +1286,7 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
func workbookCreateCellStyleField(name string) bool {
switch name {
case "font_color", "font_size", "font_weight", "font_style", "font_line",
case "font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
"background_color", "horizontal_alignment", "vertical_alignment",
"number_format", "word_wrap":
return true
@@ -1357,20 +1397,79 @@ func workbookCreateStyleDimensions(styles *workbookCreateStylePayload, baseCol,
return rows, cols
}
func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int, label string) error {
// padMatrixForStyles grows the matrix down and right so it covers every
// cell_styles range, appending empty cells for the positions the data doesn't
// reach. cell_styles are applied by writing into the matrix in place (see
// applyWorkbookCreateStylesToMatrix), so a style on a cell past the data extent
// needs a real — if empty — cell to attach to. This lets a create/write set
// styles on blank cells (reserved regions, decorative headers, empty borders).
//
// Only cell_styles contribute to the extent: cell_merges / row_sizes / col_sizes
// run as separate API calls (see workbookCreateVisualOps) and never touch the
// matrix. Ranges that start left of baseCol or above baseRow are skipped here
// (the matrix can only extend down/right) and left for the caller to reject.
// The (possibly reallocated) matrix is returned; callers must use the result.
func padMatrixForStyles(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int) [][]interface{} {
if styles == nil {
return nil
return rows
}
needRows := len(rows)
needCols := 0
if len(rows) > 0 {
needCols = len(rows[0])
}
for _, op := range styles.CellStyles {
startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range)
if err != nil || startCol < baseCol || startRow < baseRow {
continue // unparsable, or up/left of the anchor: not paddable
}
if endCol-baseCol+1 > needCols {
needCols = endCol - baseCol + 1
}
if endRow-baseRow+1 > needRows {
needRows = endRow - baseRow + 1
}
}
// Widen existing rows to needCols.
for r := range rows {
for len(rows[r]) < needCols {
rows[r] = append(rows[r], map[string]interface{}{})
}
}
// Append full empty rows to reach needRows.
for len(rows) < needRows {
row := make([]interface{}, needCols)
for c := range row {
row[c] = map[string]interface{}{}
}
rows = append(rows, row)
}
return rows
}
// applyWorkbookCreateStylesToMatrix pads the matrix to cover the cell_styles
// ranges (see padMatrixForStyles), merges each op's style into the covered
// cells, and returns the padded matrix. A range that starts left of / above the
// write anchor can't be padded to and is rejected.
func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCreateStylePayload, baseCol, baseRow int, label string) ([][]interface{}, error) {
if styles == nil {
return rows, nil
}
rows = padMatrixForStyles(rows, styles, baseCol, baseRow)
for i, op := range styles.CellStyles {
startCol, startRow, endCol, endRow, err := workbookCreateStyleRangeBounds(op.Range)
if err != nil {
return common.ValidationErrorf("%s[%d].range %q: %v", label, i, op.Range, err)
return rows, common.ValidationErrorf("%s[%d].range %q: %v", label, i, op.Range, err)
}
if startCol < baseCol || startRow < baseRow || endRow-baseRow >= len(rows) || len(rows) == 0 || endCol-baseCol >= len(rows[0]) {
return common.ValidationErrorf("%s[%d].range %q is outside the write range %s%d:%s%d",
// After padding, the matrix reaches every range that starts at or after
// the anchor; a start left of / above it can't be covered. The endRow /
// endCol checks stay as a defensive backstop (padding should have made
// them unreachable).
if startCol < baseCol || startRow < baseRow || len(rows) == 0 ||
endRow-baseRow >= len(rows) || endCol-baseCol >= len(rows[0]) {
return rows, common.ValidationErrorf("%s[%d].range %q starts outside the write range (its top-left must be at or after %s%d)",
label, i, op.Range,
columnIndexToLetter(baseCol), baseRow+1,
columnIndexToLetter(baseCol+len(rows[0])-1), baseRow+len(rows))
columnIndexToLetter(baseCol), baseRow+1)
}
for r := startRow - baseRow; r <= endRow-baseRow; r++ {
for c := startCol - baseCol; c <= endCol-baseCol; c++ {
@@ -1378,7 +1477,7 @@ func applyWorkbookCreateStylesToMatrix(rows [][]interface{}, styles *workbookCre
}
}
}
return nil
return rows, nil
}
func appendWorkbookCreateVisualOpsDryRun(dry *common.DryRunAPI, token, sheetID, sheetName string, styles *workbookCreateStylePayload) {

View File

@@ -396,6 +396,37 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
t.Errorf("horizontal_alignment occurrences = %d, want 4 in 2x2 range; cells=%s", got, raw)
}
})
t.Run("cell style past the data pads empty cells so blank cells can be styled", func(t *testing.T) {
t.Parallel()
// Data is a single cell (A1), but the style targets A1:C3 — the matrix is
// padded to 3x3 with empty cells so the style range fits, letting blank
// cells carry styling. The written range must reflect the padded extent.
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "X",
"--values", `[["a"]]`,
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:C3","background_color":"#FFEEAA"}]}]}`,
})
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
if input["range"] != "A1:C3" {
t.Errorf("range = %v, want A1:C3 (padded to the style extent)", input["range"])
}
cells, _ := input["cells"].([]interface{})
if len(cells) != 3 {
t.Fatalf("cells rows = %d, want 3 (padded); cells=%#v", len(cells), input["cells"])
}
lastRow, _ := cells[2].([]interface{})
if len(lastRow) != 3 {
t.Fatalf("last row width = %d, want 3 (padded)", len(lastRow))
}
// A blank padded cell (C3) still carries the style.
c3, _ := lastRow[2].(map[string]interface{})
c3s, _ := c3["cell_styles"].(map[string]interface{})
if c3s["background_color"] != "#FFEEAA" {
t.Errorf("padded blank cell C3 style = %#v, want background_color", c3)
}
})
t.Run("style-only payload (cell_merges) still fills and emits merge_cells", func(t *testing.T) {
t.Parallel()
// Previously workbookCreateStyleDimensions only counted cell_styles, so a
@@ -595,3 +626,60 @@ func deepEqualJSON(a, b interface{}) bool {
}
return a == b
}
// TestApplyWorkbookCreateStylesToMatrix covers the pad-then-style behavior
// directly: a style range past the data grows the matrix with empty cells (so
// blank cells can be styled), an in-range style leaves the matrix size alone,
// and a range up/left of the anchor — which padding can't reach — is rejected.
func TestApplyWorkbookCreateStylesToMatrix(t *testing.T) {
t.Parallel()
cell := func() interface{} { return map[string]interface{}{} }
t.Run("pads down and right for a style past the data", func(t *testing.T) {
t.Parallel()
matrix := [][]interface{}{{cell()}} // 1x1 data
styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{
{Range: "A1:C3", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"background_color": "#FFEEAA"}}},
}}
out, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 0, 0, "--styles")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out) != 3 || len(out[0]) != 3 || len(out[2]) != 3 {
t.Fatalf("padded matrix = %d rows x %v cols, want 3x3", len(out), out)
}
// A blank padded corner (C3) carries the style.
c3, _ := out[2][2].(map[string]interface{})
c3s, _ := c3["cell_styles"].(map[string]interface{})
if c3s["background_color"] != "#FFEEAA" {
t.Errorf("padded cell C3 = %#v, want background_color", c3)
}
})
t.Run("no pad when the style is within the data", func(t *testing.T) {
t.Parallel()
matrix := [][]interface{}{{cell(), cell()}, {cell(), cell()}} // 2x2 data
styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{
{Range: "A1:B2", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"font_weight": "bold"}}},
}}
out, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 0, 0, "--styles")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out) != 2 || len(out[0]) != 2 {
t.Errorf("matrix = %dx%d, want 2x2 (no pad)", len(out), len(out[0]))
}
})
t.Run("rejects a range up/left of the anchor", func(t *testing.T) {
t.Parallel()
matrix := [][]interface{}{{cell()}} // anchored at C3 (col 2, row 2)
styles := &workbookCreateStylePayload{CellStyles: []workbookCreateCellStyleOp{
{Range: "A1", Style: map[string]interface{}{"cell_styles": map[string]interface{}{"font_weight": "bold"}}},
}}
_, err := applyWorkbookCreateStylesToMatrix(matrix, styles, 2, 2, "--styles")
if err == nil || !strings.Contains(err.Error(), "starts outside the write range") {
t.Errorf("err = %v, want 'starts outside the write range'", err)
}
})
}

View File

@@ -11,6 +11,7 @@ import (
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"os"
"path/filepath"
"strconv"
"strings"
@@ -111,10 +112,10 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
// 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.
// font-family, 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",
@@ -165,6 +166,9 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
if err != nil {
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
}
if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil {
return nil, err
}
if err := requireAnyStyleFlag(runtime); err != nil {
return nil, err
}
@@ -200,6 +204,19 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
return input, nil
}
// csvPutStdinIsPipe reports whether process stdin is a non-interactive pipe or
// redirect (rather than an interactive terminal), so an omitted --csv can be
// satisfied from it without risking a hang on a real terminal. Overridable in
// tests. A char device is a terminal; anything else (pipe, redirect, /dev/null)
// counts as piped input.
var csvPutStdinIsPipe = func() bool {
fi, err := os.Stdin.Stat() //nolint:forbidigo // pipe detection needs the real process fd; IOStreams.In is a plain io.Reader without Stat
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice == 0
}
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet. A cell whose
// text starts with = is evaluated as a formula; use +cells-set for styles / notes / images.
var CsvPut = common.Shortcut{
@@ -222,6 +239,27 @@ var CsvPut = common.Shortcut{
}
cmd.MarkFlagsOneRequired("start-cell", "range")
cmd.MarkFlagsMutuallyExclusive("start-cell", "range")
// Let a piped CSV satisfy --csv when the flag is omitted: agents
// routinely redirect a file into stdin but forget the `--csv -`, so
// `+csv-put ... < data.csv` would otherwise fail its first try on a
// missing --csv. Relax the required-gate (flag-defs marks --csv
// required) so an absent value surfaces csvPutInput's own typed error
// instead of cobra's bare "required flag(s) ... not set"; then, in
// PreRunE (which cobra runs before it validates required flags), default
// an omitted --csv to "-" when stdin is a non-interactive pipe so the
// standard stdin-resolution path reads it. The pipe guard means an
// interactive terminal never blocks waiting on stdin — a real miss still
// errors.
if fl := cmd.Flags().Lookup("csv"); fl != nil {
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
}
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
if v, _ := c.Flags().GetString("csv"); strings.TrimSpace(v) == "" && csvPutStdinIsPipe() {
_ = c.Flags().Set("csv", "-")
}
return nil
}
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := guardCSVValueIsNotFilePath(runtime); err != nil {
@@ -450,6 +488,9 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s
if err != nil {
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
}
if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil {
return nil, err
}
validation, err := buildDropdownValidation(runtime)
if err != nil {
return nil, err
@@ -625,23 +666,23 @@ func rangeDimensions(rangeStr string) (rows, cols int, err error) {
}
rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" {
return 0, 0, fmt.Errorf("empty range")
return 0, 0, fmt.Errorf("empty range") //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
}
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 0, 0, fmt.Errorf("invalid cell ref %q", parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
}
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)
return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
}
if endRow < startRow || endCol < startCol {
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0])
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0]) //nolint:forbidigo // intermediate error; callers wrap it into a typed --range/--source-range validation error
}
return endRow - startRow + 1, endCol - startCol + 1, nil
}
@@ -692,9 +733,30 @@ func letterToColumnIndex(letters string) int {
return n - 1
}
// maxStampMatrixCells bounds how many per-cell maps a fan-out / stamp shortcut
// will materialize from a single A1 range. The backing tools take an explicit
// cells matrix, so the CLI must expand a range like "A1:Z100000" into rows×cols
// maps before sending it — an unbounded blow-up (2.6M cells ≈ 900MB heap, then
// doubled again by json.Marshal) that OOMs the process before the request even
// leaves. 200000 matches the documented --max-cells safety cap.
const maxStampMatrixCells = 200000
// checkStampMatrixBudget rejects a range whose materialized cell count would
// exceed maxStampMatrixCells, before fillCellsMatrix allocates it. rows*cols is
// computed in int64 to stay safe against overflow on pathological ranges.
func checkStampMatrixBudget(flagName, rangeStr string, rows, cols int) error {
if total := int64(rows) * int64(cols); total > maxStampMatrixCells {
return sheetsValidationForFlag(flagName,
"range %q covers %d cells, over the %d-cell safety cap; narrow the range or split it across smaller ranges",
rangeStr, total, maxStampMatrixCells)
}
return nil
}
// 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.
// Callers MUST gate the dimensions through checkStampMatrixBudget first.
func fillCellsMatrix(rows, cols int, prototype map[string]interface{}) [][]interface{} {
cells := make([][]interface{}, rows)
for r := range cells {

View File

@@ -25,8 +25,8 @@ import (
// TestSheetMediaParentType pins the token→parent_type mapping that every
// sheets image-upload entry point funnels through. Native spreadsheet tokens
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_"
// synthetic token and must upload with "office_sheet_file".
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or
// "local_office_" synthetic token and must upload with "office_sheet_file".
func TestSheetMediaParentType(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -36,9 +36,12 @@ func TestSheetMediaParentType(t *testing.T) {
}{
{"native spreadsheet token", "shtcnABC123", sheetImageParentType},
{"empty token", "", sheetImageParentType},
{"office imported token", "fake_office_abc123", officeSheetFileParentType},
{"office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType},
{"prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
{"fake_office imported token", "fake_office_abc123", officeSheetFileParentType},
{"fake_office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType},
{"local_office imported token", "local_office_abc123", officeSheetFileParentType},
{"local_office token, only the prefix", localOfficeTokenPrefix, officeSheetFileParentType},
{"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
{"local_office prefix mid-string is not matched", "shtlocal_office_abc", sheetImageParentType},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -62,7 +65,8 @@ func TestUploadSheetImage_ParentType(t *testing.T) {
wantParentType string
}{
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
{"office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
{"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
{"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {

View File

@@ -0,0 +1,272 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"io"
"runtime"
"strings"
"testing"
)
// These benchmarks back the memory review of the sheets fan-out / download
// paths. They measure two hot spots:
//
// 1. fillCellsMatrix — fan-out shortcuts (+cells-set-style, +dropdown-set,
// +cells-batch-set-style, +dropdown-update) expand one A1 range into a
// rows×cols matrix of per-cell maps. A tiny input string ("A1:Z100000")
// explodes into millions of heap maps with no upper bound.
//
// 2. the export-download reader — strings.NewReader(string(rawBody)) copies
// the whole downloaded file once more before saving it.
//
// Run: go test ./shortcuts/sheets -run XXX -bench 'FillCellsMatrix|DownloadReader' -benchmem
var styleProto = map[string]interface{}{
"cell_styles": map[string]interface{}{"bold": true, "fg_color": "#FF0000"},
"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "solid"}},
}
func benchFillCellsMatrix(b *testing.B, rows, cols int) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
m := fillCellsMatrix(rows, cols, styleProto)
if len(m) != rows {
b.Fatalf("bad matrix")
}
}
}
func BenchmarkFillCellsMatrix_100(b *testing.B) { benchFillCellsMatrix(b, 10, 10) } // A1:J10
func BenchmarkFillCellsMatrix_10K(b *testing.B) { benchFillCellsMatrix(b, 1000, 10) } // A1:J1000
func BenchmarkFillCellsMatrix_100K(b *testing.B) { benchFillCellsMatrix(b, 10000, 10) } // A1:J10000
func BenchmarkFillCellsMatrix_2600K(b *testing.B) { benchFillCellsMatrix(b, 100000, 26) } // A1:Z100000
// TestFanoutMatrixPeakMemory reports the concrete resident-heap delta of
// materializing a large fan-out matrix, so the review doc can quote real MB.
// Not an assertion — it prints numbers under `go test -v -run PeakMemory`.
func TestFanoutMatrixPeakMemory(t *testing.T) {
if testing.Short() {
t.Skip("skipping memory probe in -short")
}
cases := []struct {
name string
rows, cols int
}{
{"A1:Z10000 (260K cells)", 10000, 26},
{"A1:Z100000 (2.6M cells)", 100000, 26},
}
for _, c := range cases {
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
m := fillCellsMatrix(c.rows, c.cols, styleProto)
runtime.ReadMemStats(&after)
runtime.KeepAlive(m)
t.Logf("%-26s heap +%6.1f MB (%d total allocs)",
c.name,
float64(after.HeapAlloc-before.HeapAlloc)/(1024*1024),
after.Mallocs-before.Mallocs)
}
}
// --- +table-put / +workbook-create matrix materialization (sibling #1 path) ---
//
// buildSheetMatrix turns the caller's --sheets/--values into a rows×cols matrix
// of per-cell maps, the same unbounded blow-up as fillCellsMatrix but on the
// table-put ingress (tablePutMaxCellsPerWrite only slices the *write*, not this
// in-memory build). checkCellBudget rejects oversized payloads before this runs.
func makeTypelessSpec(rows, cols int) *tableSheetSpec {
c := make([]tableColumnSpec, cols)
r := make([][]interface{}, rows)
for i := range r {
row := make([]interface{}, cols)
for j := range row {
row[j] = "x"
}
r[i] = row
}
return &tableSheetSpec{Columns: c, Rows: r}
}
func benchBuildSheetMatrix(b *testing.B, rows, cols int) {
spec := makeTypelessSpec(rows, cols)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
m, err := buildSheetMatrix(spec, true)
if err != nil || len(m) != rows+1 {
b.Fatalf("bad matrix")
}
}
}
func BenchmarkBuildSheetMatrix_100K(b *testing.B) { benchBuildSheetMatrix(b, 10000, 10) } // 100K cells
func BenchmarkBuildSheetMatrix_2600K(b *testing.B) { benchBuildSheetMatrix(b, 100000, 26) } // 2.6M cells
// TestTablePutMatrixPeakMemory reports the resident-heap delta of materializing
// a large table-put matrix (the cost checkCellBudget now prevents), so the
// review doc can quote real MB. Not an assertion — prints under -v -run PeakMemory.
func TestTablePutMatrixPeakMemory(t *testing.T) {
if testing.Short() {
t.Skip("skipping memory probe in -short")
}
for _, c := range []struct {
name string
rows, cols int
}{
{"100000×26 (2.6M cells)", 100000, 26},
} {
spec := makeTypelessSpec(c.rows, c.cols)
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
m, _ := buildSheetMatrix(spec, true)
runtime.ReadMemStats(&after)
runtime.KeepAlive(m)
t.Logf("%-24s buildSheetMatrix heap +%6.1f MB (%d total allocs)",
c.name,
float64(after.HeapAlloc-before.HeapAlloc)/(1024*1024),
after.Mallocs-before.Mallocs)
}
}
// --- export-download reader copy ---
func benchDownloadReader(b *testing.B, size int, useStringCopy bool) {
raw := bytes.Repeat([]byte("x"), size)
sink := make([]byte, 32*1024)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var r io.Reader
if useStringCopy {
r = strings.NewReader(string(raw)) // current code: extra full-size copy
} else {
r = bytes.NewReader(raw) // fix: no copy
}
for {
if _, err := r.Read(sink); err != nil {
break
}
}
}
}
// --- fan-out cell-budget cap (fix for the unbounded matrix blow-up) ---
func TestStampMatrixBudgetCap(t *testing.T) {
// 199992 cells (7692×26) sits just under the 200000 cap → allowed.
if err := checkStampMatrixBudget("range", "A1:Z7692", 7692, 26); err != nil {
t.Fatalf("199992 cells should pass, got: %v", err)
}
// Exactly at the cap → allowed.
if err := checkStampMatrixBudget("range", "A1:A200000", 200000, 1); err != nil {
t.Fatalf("200000 cells (== cap) should pass, got: %v", err)
}
// Just over the cap → rejected.
if err := checkStampMatrixBudget("range", "A1:A200001", 200001, 1); err == nil {
t.Fatal("200001 cells should be rejected")
}
// The pathological case from the review (2.6M cells) → rejected.
if err := checkStampMatrixBudget("ranges", "Sheet1!A1:Z100000", 100000, 26); err == nil {
t.Fatal("2.6M-cell fan-out should be rejected")
}
}
// --- sibling cap gaps: +table-put/+workbook-create payload, batch aggregate,
// batch-update operation count (follow-up to the single fan-out cap) ---
// TestTablePutCellBudgetCap covers the --sheets/--values materialization cap:
// buildSheetMatrix builds the whole matrix in memory, so the total cell count is
// bounded before that allocation, summed across all sheets.
func TestTablePutCellBudgetCap(t *testing.T) {
// 1000×1000 = 1,000,000 == cap → allowed.
atCap := &tablePayload{Sheets: []tableSheetSpec{{
Columns: make([]tableColumnSpec, 1000),
Rows: make([][]interface{}, 1000),
}}}
if err := atCap.checkCellBudget(); err != nil {
t.Fatalf("1,000,000 cells (== cap) should pass, got: %v", err)
}
// 1000×1001 = 1,001,000 > cap → rejected.
over := &tablePayload{Sheets: []tableSheetSpec{{
Columns: make([]tableColumnSpec, 1000),
Rows: make([][]interface{}, 1001),
}}}
if err := over.checkCellBudget(); err == nil {
t.Fatal("1,001,000 cells should be rejected")
}
// Budget is summed across sheets, not per-sheet: 600k + 600k = 1.2M > cap.
twoSheets := &tablePayload{Sheets: []tableSheetSpec{
{Columns: make([]tableColumnSpec, 1000), Rows: make([][]interface{}, 600)},
{Columns: make([]tableColumnSpec, 1000), Rows: make([][]interface{}, 600)},
}}
if err := twoSheets.checkCellBudget(); err == nil {
t.Fatal("1.2M cells across two sheets should be rejected")
}
}
// TestBatchStampAggregateCap covers the batch fan-out aggregate budget — the
// per-range cap can't stop many ranges from summing past the matrix ceiling.
func TestBatchStampAggregateCap(t *testing.T) {
if err := checkBatchStampBudget(maxStampMatrixCells); err != nil {
t.Fatalf("aggregate == cap should pass, got: %v", err)
}
if err := checkBatchStampBudget(maxStampMatrixCells + 1); err == nil {
t.Fatal("aggregate over cap should be rejected")
}
}
// TestBatchFanoutRangeCountCap drives a fan-out shortcut with > maxBatchRanges
// ranges and expects the shared validateDropdownRanges cap to reject it.
func TestBatchFanoutRangeCountCap(t *testing.T) {
ranges := make([]string, maxBatchRanges+1)
for i := range ranges {
ranges[i] = "sheet1!A1"
}
rangesJSON, _ := json.Marshal(ranges)
_, _, err := runShortcutCapturingErr(t, CellsBatchSetStyle, []string{
"--url", testURL,
"--ranges", string(rangesJSON),
"--font-weight", "bold",
"--dry-run",
})
requireValidation(t, err, "at most")
}
// TestBatchOperationsCountCap covers the +batch-update sub-operation count cap.
func TestBatchOperationsCountCap(t *testing.T) {
ops := make([]interface{}, maxBatchOperations+1)
for i := range ops {
ops[i] = map[string]interface{}{"shortcut": "+cells-set", "input": map[string]interface{}{}}
}
_, err := translateBatchOperations(ops, testURL)
if err == nil || !strings.Contains(err.Error(), "at most") {
t.Fatalf("expected operations count cap error, got: %v", err)
}
}
// BenchmarkStampBudget_RejectsOversized is the "after" side of the fix: the same
// A1:Z100000 input that BenchmarkFillCellsMatrix_2600K shows costing ~917MB /
// 5.3M allocs is now rejected up front, allocating only the error string.
func BenchmarkStampBudget_RejectsOversized(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if err := checkStampMatrixBudget("range", "A1:Z100000", 100000, 26); err == nil {
b.Fatal("expected rejection")
}
}
}
func BenchmarkDownloadReader_StringCopy_1MB(b *testing.B) { benchDownloadReader(b, 1<<20, true) }
func BenchmarkDownloadReader_BytesNoCopy_1MB(b *testing.B) { benchDownloadReader(b, 1<<20, false) }
func BenchmarkDownloadReader_StringCopy_16MB(b *testing.B) { benchDownloadReader(b, 16<<20, true) }
func BenchmarkDownloadReader_BytesNoCopy_16MB(b *testing.B) {
benchDownloadReader(b, 16<<20, false)
}

View File

@@ -35,6 +35,10 @@ func Shortcuts() []common.Shortcut {
if hasFlag(all[i].Flags, "spreadsheet-token") {
all[i].PostMount = withTokenAlias(all[i].PostMount)
}
// Sheets-scoped flag ergonomics (unknown-flag hints with the valid
// flags inlined, enum vocabulary normalization) ride the same
// PostMount composition, so no other domain's behavior shifts.
all[i].PostMount = withFlagErgonomics(all[i].PostMount)
}
return all
}
@@ -70,6 +74,7 @@ func shortcutList() []common.Shortcut {
return []common.Shortcut{
// lark_sheet_workbook
WorkbookInfo,
RevisionGet,
SheetCreate,
SheetDelete,
SheetRename,
@@ -95,6 +100,9 @@ func shortcutList() []common.Shortcut {
DimUngroup,
DimMove,
// lark_sheet_changeset
ChangesetGet,
// lark_sheet_read_data
CellsGet,
CsvGet,
@@ -105,6 +113,9 @@ func shortcutList() []common.Shortcut {
CellsSearch,
CellsReplace,
// lark_sheet_formula_verify
FormulaVerify,
// lark_sheet_write_cells
CellsSet,
CellsSetStyle,
@@ -148,5 +159,10 @@ func shortcutList() []common.Shortcut {
CellsBatchClear,
DropdownUpdate,
DropdownDelete,
// lark_sheet_history
HistoryList,
HistoryRevert,
HistoryRevertStatus,
}
}

View File

@@ -6,7 +6,6 @@
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`
@@ -195,7 +194,6 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
| `not exist` | 使用了错误的 token | 检查 token 类型wiki 链接必须先查询获取 `obj_token` |
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_typedocx/doc/sheet/slides/bitable |
| `232140101` / `232140100` / `233523001`(常见于 `drive +import` 的 `job_error_msg` | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 |
### 授权当前应用访问文档

View File

@@ -45,15 +45,13 @@ lark-cli calendar +agenda --as user
| 场景 | 前置要求 |
|------|----------|
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
| 编辑已有日程 | 先定位目标日程 `event_id` |
| 编辑/删除重复性日程 | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),按操作范围(仅此次/全部/此次及后续)执行 |
| 编辑已有日程 | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID |
| 删除/修改后验证 | 等待 2 秒再查询API 最终一致性),不要告知用户你等待了 |
| 调用任何 Shortcut | 先读其对应 reference 文档 |
## 核心概念
- **日程实例Instance**:重复性日程展开后的具体时间实例。「仅此次」操作时使用具体实例的 `event_id`;「全部」或「此次及后续」操作时需对原重复日程操作(使用原日程 `event_id`),并按需处理例外
- **重复性日程例外Exception**:对重复性日程某次实例做过「仅此次」编辑后产生的独立日程(拥有独立 `event_id`)。删除/更新「全部」时必须同时处理例外,否则例外会残留。
- **日程实例Instance**:重复性日程展开后的具体时间实例。操作重复日程的某次实例时,必须先定位该实例的 `event_id`,禁止使用原重复日程 `event_id`
- **全天日程All-day Event**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
- **会议室Room**"room"不是"房间",是"会议室"。会议室是日程的一种参与人resource attendee不能脱离日程单独预定。
@@ -73,7 +71,6 @@ lark-cli calendar +agenda --as user
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill`+meeting` |
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting``meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
| 编辑/删除重复性日程(「改这个重复日程」「删掉后面的」「全部取消」等) | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),确认操作范围后执行 |
## 任务类型分流

View File

@@ -47,7 +47,6 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
> 失败保护:若添加参会人失败(如 open_id 错误CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
## 高级用法(完整 API 命令)
@@ -73,16 +72,9 @@ lark-cli calendar events create \
lark-cli schema calendar.event.attendees.create
## 添加参会人
lark-cli calendar event.attendees create \
--as user \
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}'
## 添加需要审批的会议室approval_reason 最大 200 字符)
lark-cli calendar event.attendees create \
--as user \
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
--data '{"attendees": [{"type": "resource", "room_id": "omm_xxx", "approval_reason": "申请原因"}]}'
# 可选第三步(推荐):若第二步失败,回滚删除空日程
## 查看完整参数定义
lark-cli schema calendar.events.delete

View File

@@ -1,90 +0,0 @@
# 重复性日程操作规范
重复性日程的编辑/删除分为三种范围:「仅此次」「全部」「此次及后续」。用户未明确范围时,**必须询问确认**。
## 关键概念
- **event_id 结构**`event_id` 的格式为 `{event_uid}_{originalTime}`。普通日程或重复性日程本体的 `originalTime``0`;例外的 `originalTime > 0`,代表该例外在原重复性序列中本来的时间位置。因此 `{event_uid}_0` 即为原重复性日程的 `event_id`
- **原重复性日程**:携带 `rrule` 的日程本体,`event_id` 形如 `{event_uid}_0`。系列的所有属性标题、时间、rrule、描述等都挂在本体上。
- **例外Exception**:对某次实例做过「仅此次」编辑后产生的独立日程,`event_id` 形如 `{event_uid}_{originalTime}``originalTime > 0`)。通过 `event_uid` 部分即可关联回原重复性日程。
- 删除/更新原重复性日程 **不会** 级联处理例外——必须手动逐个处理。
## 前置步骤(所有范围通用)
1. 通过 `+agenda``+search-event` 定位重复性日程,获取原重复性日程的 `event_id`
2. 通过 `events instance_view``+agenda` 列出实例,识别哪些是例外(`event_id``originalTime > 0` 的即为例外)。
3. 确认用户的操作范围。
## 编辑全部(更新时间)
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --start ... --end ...` | 更新原重复性日程的时间 |
| 2 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<例外ID>","need_notification":false}'` (逐个) | 时间变更后例外已无意义,必须删除 |
> 理由:更新时间会改变重复起止点,例外日程的原始占位已变,若保留会导致时间冲突或残留。
## 编辑全部(更新非时间字段)
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --summary ... --description ...` | 更新原重复性日程的标题/描述等 |
| 2 | `lark-cli calendar +update --event-id <例外ID> --summary ... --description ...` (逐个) | 同步更新例外日程的对应字段 |
> 理由:例外已脱离原重复性日程独立存在,不会自动继承原日程的更新。
## 删除全部
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<原重复日程ID>","need_notification":true}'` | 删除重复性日程本体 |
| 2 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<例外ID>","need_notification":false}'` (逐个) | 删除所有例外日程 |
> 理由:例外是独立实体,删除原重复性日程不会级联删除例外。
## 编辑此次及后续
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --rrule "FREQ=...;UNTIL=<截止日期>"` | 截短原重复性日程UNTIL 设为指定时间前一次实例的日期) |
| 2 | `lark-cli calendar events delete ...` (逐个) | 删除指定时间之后(含)的例外日程 |
| 3 | `lark-cli calendar +create --summary ... --start <指定时间> --end ... --rrule "FREQ=..." --attendee-ids ...` | 从指定时间开始创建新的重复性日程(即「后续」部分,携带编辑后的内容) |
> UNTIL 计算规则:若用户选择「从第 N 次开始编辑」UNTIL 应设置为第 N-1 次实例的日期(即保留到指定时间之前的最后一次)。
> 新日程应继承原日程的参会人、会议室等配置(除非用户明确要修改)。
## 删除此次及后续
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --rrule "FREQ=...;UNTIL=<截止日期>"` | 截短原重复性日程UNTIL 设为指定时间前一次实例的日期) |
| 2 | `lark-cli calendar events delete ...` (逐个) | 删除指定时间之后(含)的例外日程 |
> 与「编辑此次及后续」的区别:不需要步骤 3创建新的重复性日程因为目标是删除后续而非替换。
## 仅此次
- **编辑仅此次**:通过 `+agenda` / `+search-event` 定位到具体实例的 `event_id`,然后正常调用 `+update`
- **删除仅此次**:定位到具体实例的 `event_id`,调用 `events delete`
## 用户意图映射
| 用户表达 | 操作范围 |
|----------|----------|
| 「改这个重复日程的标题」「全部改」「每次都改」 | 编辑全部 |
| 「删掉这个重复日程」「取消所有」 | 删除全部 |
| 「从下周开始改时间」「后面的都改」 | 编辑此次及后续 |
| 「从下周开始不要了」「后面的都删」 | 删除此次及后续 |
| 「就改这一次」「只删这一次」 | 仅此次 |
| 未明确范围 | **必须询问用户** |
## 注意事项
- 涉及时间戳计算(如推算 UNTIL 日期)时,必须调用系统命令或脚本,禁止心算。
## 参考
- [lark-calendar](../SKILL.md) — 日历全部命令
- [lark-calendar-update](lark-calendar-update.md) — 更新日程 Shortcut
- [lark-calendar-create](lark-calendar-create.md) — 创建日程 Shortcut
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -43,7 +43,7 @@ lark-cli calendar +update \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID详见 [重复日程操作规范](lark-calendar-recurring.md) |
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程要先定位到目标实例的 `event_id`,不要直接使用原重复日程 ID |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用 `primary` |
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 |
@@ -65,7 +65,7 @@ lark-cli calendar +update \
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids``--remove-attendee-ids`
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`
- 会议室是 resource attendee必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行
- 更新重复性日程的某一次实例时,必须先通过 `+agenda``+search-event` 或实例视图定位该实例的 `event_id`
- 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。

View File

@@ -5,7 +5,7 @@ description: "飞书云文档Docx / Wiki 文档):读取和编辑飞书文
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli docs --help;lark-cli mindnotes --help"
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help; lark-cli mindnotes nodes list --help; lark-cli mindnotes nodes create --help"
---
# docs
@@ -21,25 +21,12 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
## 前置条件 — 执行操作前必读
**CRITICAL — 按任务命中以下阅读规则;执行对应操作前MUST 先用 Read 工具读取命中的文件,缺一不可:**
**CRITICAL — 执行对应操作前MUST 先用 Read 工具读取以下文件,缺一不可:**
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
2. **读取文档(`docs +fetch`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和 [`lark-doc-style.md`](references/style/lark-doc-style.md)(元素选择、丰富度规则、颜色语义);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
1. **所有文档操作** → 必读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)
用于认证、权限处理、身份选择和全局参数。
2. **读取文档(`docs +fetch`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)
用于选择 `--scope` / `--detail`、局部读取策略,以及理解 `<fragment>` / `<excerpt>` 输出结构。
3. **创建文档**(从零创建 / 导入)→ 按顺序读取:
1. [`lark-doc-design-philosophy.md`](references/lark-doc-design-philosophy.md)。
2. [`lark-doc-create.md`](references/lark-doc-create.md)。
3. 格式文件二选一:默认读 [`lark-doc-xml.md`](references/lark-doc-xml.md);用户明确 Markdown、提供 `.md` 文件或要求导入 Markdown 时,改读 [`lark-doc-md.md`](references/lark-doc-md.md)。
4. **编辑已有文档** → 按顺序读取:
1. 先判断是否需要 Philosophy简单替换、错别字、日期 / 人名 / 数字替换不读;重写、润色、排版、补正文 / 图表、调结构、保真改写时,加读 [`lark-doc-design-philosophy.md`](references/lark-doc-design-philosophy.md),并按需只读一个体裁 router。
2. [`lark-doc-update.md`](references/lark-doc-update.md)。
3. 需要读取现状或写后验证时,按第 2 条读取 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)。
4. 格式文件二选一:默认读 [`lark-doc-xml.md`](references/lark-doc-xml.md);用户明确 Markdown、提供 `.md` 文件或要求写入 Markdown 时,改读 [`lark-doc-md.md`](references/lark-doc-md.md)。
**Philosophy 负责创建与复杂编辑的设计判断Router 负责一级大类模板与必要二类分流Genre 只在 workplace/report 命中时补充细分偏好Create / Update 负责执行XML / Markdown 负责合法表达。**
**未读完命中的必读文件就执行相应操作,容易导致参数选择错误、格式错误、结构失焦或内容破坏。**
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
> **格式选择规则(全局):**
> - **创建 / 导入场景**`docs +create`,或 `docs +update --command append/overwrite` 的整段写入XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown否则默认 XML可用 callout、grid、checkbox 等富 block
@@ -47,15 +34,14 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
## 快速决策
- 用户要**复制文档 / 创建文档副本 / 另存为副本**时,切到 [`lark-drive`](../lark-drive/SKILL.md),按其中的复制指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`
- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`已有文档改写按 [`lark-doc-update.md`](references/lark-doc-update.md) 的 Observe-Diagnose-Patch Loop 先 fetch 再局部 patch明确旧文本 → 新文本的简单替换可直接 `str_replace`,但写后必须 fetch 验证;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
- block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids`
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」处理:每次更新后都按 block ID 已变更处理;需要继续或重复修改时,先重新 fetch 最新内容和 block ID不要复用旧 fetch 结果
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID插入 / 复制后要重新 fetch 才能拿到新 block ID
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
- 新增画板按复杂度处理:简单 Mermaid / SVG 图可由主 Agent 直接写入草稿;复杂图或需要专门视觉设计的 SVG 交给 SubAgent 产出完整 `<whiteboard type="svg">...</whiteboard>`;特别复杂或已有画板更新,主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
- 新增画板必须隔离到 SubAgent简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),按其中流程操作
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
@@ -83,7 +69,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status |
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |

View File

@@ -1,50 +0,0 @@
# Lark Doc Genre Routers
本目录按“一级 router 大类模板 →(仅 report/workplace 可选)二类体裁文件”组织文档体裁指南。不要一次读取整个目录。
使用流程:
1. 先读 `../lark-doc-design-philosophy.md``Route by genre`
2. 选择一个最匹配的 `_router-*.md`
3. 多数 router 本身就是一级大类模板,提供写作思路、风格、结构、元素、反模板和 final check读完即停。
4. 只有 `_router-report.md``_router-workplace.md` 保留二类 routing。它们明确命中具体体裁时只读取一个具体体裁文件。
5. 体裁文件提供结构偏好,不是固定模板。用户明确要求、已有文档风格和事实材料优先于体裁默认结构。
## Routers
- `_router-creative.md` — 文学故事大类模板
- `_router-media.md` — 资讯媒体大类模板
- `_router-opinion.md` — 观点评论大类模板
- `_router-knowledge.md` — 知识教程大类模板
- `_router-report.md` — 报告研究大类模板 + 二类 routing
- `_router-consumer.md` — 种草消费大类模板
- `_router-marketing.md` — 营销转化大类模板
- `_router-workplace.md` — 职场协作大类模板 + 二类 routing
- `_router-personal-brand.md` — 个人品牌大类模板
- `_router-platform.md` — 平台原生大类模板
## Retained second-level genre files
`_router-report.md` may route to:
- `research-report.md`
- `data-report.md`
- `white-paper.md`
- `business-analysis.md`
`_router-workplace.md` may route to:
- `memo-brief.md`
- `weekly-report.md`
- `proposal.md`
- `formal-doc.md`
- `official-redhead.md`
- `meeting-minutes.md`
- `retrospective.md`
- `prd.md`
- `technical-doc.md`
- `sop-tutorial.md`
## Expansion rule
新增二类体裁前,先确认一级大类模板无法满足当前任务;新增后必须同步更新对应 router 和 `../lark-doc-design-philosophy.md` 的路由说明,避免 agent 读取多个无关文件。

View File

@@ -1,44 +0,0 @@
# Genre Template: Consumer / 种草消费大类
本文件是一级大类模板。适用:小红书笔记、测评、合集、好物分享、探店、生活方式内容等以体验、选择和信任为核心的文档。读完本文件即可开始写作;当前不继续读取具体体裁文件。
## 写作思路
- 先确定读者场景:想买、想避坑、想比较、想收藏、想获得生活灵感,还是想验证真实体验。
- 把推荐理由写成“场景 → 标准 → 体验 / 证据 → 适合谁 / 不适合谁”。
- 没有真实材料时,不要伪装“亲测”“真实体验”“用了一周”。可以写成选购框架、测评提纲或待验证清单。
- 对价格、库存、功效、安全、资质等易变或高风险信息必须标注来源或待确认。
## 结构偏好
- **体验笔记**:场景钩子 → 核心感受 → 关键细节 → 适合 / 不适合 → 总结建议。
- **测评 / 横评**:评测标准 → 对象对比 → 优缺点 → 场景匹配 → 结论。
- **合集 / 清单**:推荐标准 → 分类 → 单项理由 → 选择建议 → 注意事项。
- **探店 / 旅行体验**:位置 / 场景 → 体验动线 → 亮点 / 雷点 → 人群建议 → 实用信息。
## 风格
- 可信、具体、有生活感;少用夸张口号。
- 可以轻松,但不能为了种草编造体验、效果或用户反馈。
- 标题可强调场景和利益点,但不要制造绝对化承诺。
## 常用元素
- 对比表、适合 / 不适合列表、评分维度、清单、注意事项 callout。
- 多产品、多店铺、多路线时用 table情绪化体验可用短段落和列表。
## 反模板
- 不要“闭眼入”“天花板”“全网最强”等无证据绝对话术。
- 不要只有夸赞,没有限制、缺点或适用人群。
- 不要把广告文案伪装成真实测评。
- 不要堆 emoji 和感叹号掩盖信息不足。
- 不要忽略价格、规格、地点、时间等关键上下文。
## Final check
- 读者能否判断这是否适合自己?
- 推荐标准和证据是否明确?
- 是否说明缺点、限制或不适合人群?
- 是否避免伪造体验、销量、功效和背书?
- 收藏、购买、到店或下一步行动是否清楚?

View File

@@ -1,44 +0,0 @@
# Genre Template: Creative / 文学故事大类
本文件是一级大类模板。适用:网文、短篇故事、同人、互动小说、剧本、故事大纲等以故事体验为核心的文档。读完本文件即可开始写作;当前不继续读取具体体裁文件。
## 写作思路
- 先判断读者要获得什么体验:爽感、悬疑、治愈、幽默、代入、角色陪伴、世界观探索,还是创作协助。
- 用“人物欲望 → 阻力 → 选择 → 代价 → 变化”组织故事,不要只堆设定。
- 开篇优先给处境、冲突、问题或画面;设定应在行动中露出,而不是先写百科。
- 若用户给了 IP、角色或世界观只使用用户提供或可确认的信息不确定的设定标注为创作假设。
## 结构偏好
- **故事正文**:钩子场景 → 人物目标 → 冲突升级 → 关键选择 → 结果 / 余味。
- **故事大纲**:一句话 premise → 主角与欲望 → 世界规则 → 主线冲突 → 阶段节点 → 结局方向。
- **剧本 / 分镜**:场景信息 → 动作线 → 对白 → 情绪 / 镜头提示;不要把解释塞进对白。
- **互动叙事**:当前状态 → 选择项 → 后果预期 → 分支收束点。
## 风格
- 具体画面优先于抽象评价;用动作、细节、对话呈现情绪。
- 叙述节奏服务体裁:悬疑保留信息差,爽文强化目标和反馈,治愈文放慢感受,剧本保持可表演。
- 保持角色声音一致;角色不能只替作者讲道理。
## 常用元素
- 人物卡、关系表、时间线、章节节点、场景清单、对白块、分支表。
- 长篇或复杂世界观可用 table 管理人物 / 阵营 / 线索;流程复杂时再考虑 whiteboard。
## 反模板
- 不要用“在一个……的世界里”替代具体开场。
- 不要先写大段设定、背景、人物履历,再进入故事。
- 不要把人物写成价值观传声筒。
- 不要用连续反转掩盖动机不足。
- 不要在同一段里混用小说叙述、剧本格式和大纲语气。
## Final check
- 第一屏是否有足够明确的钩子或人物处境?
- 主角想要什么、阻力是什么、代价是什么是否清楚?
- 角色语言、行动和情绪是否一致?
- 设定是否服务冲突,而不是独立堆砌?
- 结尾是否留下完成感、悬念或下一步创作入口?

View File

@@ -1,44 +0,0 @@
# Genre Template: Knowledge / 知识教程大类
本文件是一级大类模板。适用科普、教程、指南、攻略、FAQ、资源合集、知识库等以理解、学习和执行为核心的文档。读完本文件即可开始写作当前不继续读取具体体裁文件。
## 写作思路
- 先确定读者起点:零基础、已有经验、正在排错、准备决策,还是要复用为知识库。
- 把读者任务拆成“理解概念 / 完成操作 / 做选择 / 排查问题 / 找资源”。
- 先给最短可行路径,再解释原理和例外;不要先讲完整理论体系。
- 对风险、前置条件、适用范围和不适用范围要明确。
## 结构偏好
- **科普解释**:问题 / 误区 → 核心概念 → 例子 → 机制 → 边界。
- **教程 / 指南**:目标 → 前置条件 → 步骤 → 验证方式 → 常见问题 → 下一步。
- **FAQ**:问题分组 → 短答案 → 详细解释 / 操作 → 相关链接或注意事项。
- **资源合集**:使用场景 → 推荐标准 → 分类清单 → 选择建议 → 维护说明。
## 风格
- 具体、可操作、少炫技。术语第一次出现要解释。
- 每一步尽量可验证:读者应知道自己是否做对。
- 不要为了显得专业而增加读者不需要的理论。
## 常用元素
- 步骤列表、checkbox、FAQ 表、术语表、对比表、代码块、风险 callout。
- 流程复杂、依赖多或排错路径多时可用 whiteboard简单操作用列表更好。
## 反模板
- 不要用“首先我们来了解背景”拖延进入任务。
- 不要只有原则,没有步骤或例子。
- 不要把 FAQ 写成文章段落,让读者无法检索。
- 不要省略前置条件、版本、权限、环境差异。
- 不要把资源合集写成无标准的链接堆砌。
## Final check
- 读者是否能从第一屏知道自己能完成什么?
- 前置条件、步骤、验证方式是否清楚?
- 概念解释是否服务任务,而不是单独炫技?
- FAQ / 表格 / 代码块是否便于检索和复用?
- 是否标注了限制、例外和常见错误?

View File

@@ -1,45 +0,0 @@
# Genre Template: Marketing / 营销转化大类
本文件是一级大类模板。适用:广告、软文、详情页、带货、活动、公关、私域话术、转化文案等以认知、信任和行动为核心的文档。读完本文件即可开始写作;当前不继续读取具体体裁文件。
## 写作思路
- 先明确目标:认知、兴趣、咨询、报名、购买、复购、安抚舆情,还是统一口径。
- 写清楚受众、场景、痛点、承诺、证据、行动入口;不要先追求文采。
- 卖点必须来自产品、服务、案例或用户提供资料;没有证据时写成待补充,不要编造。
- 公关和声明类优先事实边界、责任边界和下一步动作,不能只做情绪安抚。
## 结构偏好
- **广告 / 短文案**:受众场景 → 核心利益 → 记忆点 → 行动。
- **软文 / 内容营销**:问题场景 → 误区 / 机会 → 解决方案 → 证据 → CTA。
- **详情页 / 落地页**:首屏承诺 → 痛点 → 方案 → 卖点模块 → 证明 → FAQ → CTA。
- **活动 / 私域话术**:对象 → 利益点 → 规则 → 时间 / 门槛 → 引导动作。
- **公关沟通**:事实 → 影响 → 态度 / 原则 → 措施 → 后续同步机制。
## 风格
- 明确、有推动力,但不过度承诺。
- 面向外部传播时保持品牌一致;面向私域时更像真人沟通,但不油腻。
- 话术要短、可发送、可执行,避免大段官腔。
## 常用元素
- 卖点表、受众-痛点-利益表、FAQ、CTA、时间线、风险 callout、话术分支。
- 多渠道活动可用 table复杂转化链路可用 whiteboard。
## 反模板
- 不要编造功效、销量、资质、奖项、背书、用户评价。
- 不要只有华丽形容词,没有利益点和证据。
- 不要把所有内容都写成“限时抢购”。
- 不要忽视合规、隐私、价格、适用范围和退改规则。
- 不要把公关稿写成自夸稿,回避核心问题。
## Final check
- 目标动作是否明确?
- 受众、场景、痛点和核心利益是否具体?
- 每个重要承诺是否有证据或待补充标记?
- 结构是否降低理解和行动成本?
- 是否避免夸大、误导和不合规表达?

View File

@@ -1,44 +0,0 @@
# Genre Template: Media / 资讯媒体大类
本文件是一级大类模板。适用:新闻、快讯、深度报道、人物报道、访谈、媒体稿等以事实传递和公共理解为核心的文档。读完本文件即可开始写作;当前不继续读取具体体裁文件。
## 写作思路
- 先区分任务是“快速告知”“解释背景”“呈现人物”“整理访谈”还是“对外发布”。
- 明确事实层级:已确认事实、来自材料的说法、推断、待确认信息必须分开。
- 重要信息前置:谁、何时、何地、发生了什么、影响是什么、下一步是什么。
- 多方材料冲突时,不要替任何一方下结论;呈现来源、口径和不确定性。
## 结构偏好
- **快讯 / 短消息**:核心事实 → 影响 / 背景一句 → 后续进展。
- **常规报道**:导语 → 关键事实 → 背景 → 相关方回应 → 影响与后续。
- **解释性报道**:问题 → 为什么重要 → 时间线 / 机制 → 各方观点 → 尚待确认。
- **人物 / 访谈**:人物切口 → 关键经历 / 观点 → 代表性细节 → 语录 / 问答 → 现实意义。
## 风格
- 准确、克制、可核验;少用情绪化形容词。
- 标题可以清楚有信息量,但不要标题党。
- 引述材料时区分直接引用、转述和总结;没有来源不要编造“相关人士表示”。
## 常用元素
- 时间线、事实表、人物 / 机构关系表、问答块、blockquote、待确认 callout。
- 多事件、多主体、多时间点时优先 table因果或时间推进复杂时可用 whiteboard。
## 反模板
- 不要把评论写成新闻,不要把宣传稿写成报道。
- 不要使用“引发热议”“业内人士认为”等无来源表达。
- 不要为了完整性补齐不存在的细节。
- 不要用大量背景压住最新事实。
- 不要把采访整理成流水账;要保留问题线索和观点递进。
## Final check
- 关键事实是否前置,且可区分已确认 / 待确认?
- 是否清楚标注来源、材料口径和事实边界?
- 标题、导语和正文是否一致,不夸大?
- 背景是否帮助理解,而不是稀释重点?
- 读者读完是否知道事件影响和后续关注点?

View File

@@ -1,43 +0,0 @@
# Genre Template: Opinion / 观点评论大类
本文件是一级大类模板。适用:时评、商业评论、文化评论、专栏、随笔、杂文、观点文章等以判断、立场和论证为核心的文档。读完本文件即可开始写作;当前不继续读取具体体裁文件。
## 写作思路
- 先写清楚核心判断:你到底赞成、反对、解释、警惕还是重新定义一个问题。
- 评论不是事实复述。事实只承担证据、背景或反例功能。
- 用“观点 → 理由 → 证据 → 反方可能意见 → 回应 → 结论”推进,而不是堆观点。
- 观点强度要匹配证据强度;证据不足时用假设、可能、值得观察,而不是确定断言。
## 结构偏好
- **直接评论**:切入点 → 核心观点 → 2-3 个论点 → 反方视角 → 结论 / 建议。
- **商业 / 文化观察**:现象 → 误读 → 深层机制 → 案例 / 证据 → 可迁移启发。
- **随笔 / 专栏**:具体经验或场景 → 观察 → 转折 → 思考展开 → 收束到一个余味。
## 风格
- 可以有锋芒,但必须清楚、公允、有边界。
- 少用宏大词,多用具体判断;避免“时代洪流”“底层逻辑”等空泛表达。
- 随笔可保留个人声音,但不能编造经历或把感受包装成事实。
## 常用元素
- 论点列表、论据表、正反观点表、引用块、结论 callout。
- 长文可用小标题表达论证推进;不要为了整齐把每节写成同样长度。
## 反模板
- 不要只有态度,没有论证。
- 不要把单个案例扩大成普遍规律。
- 不要把复杂问题写成二元对立。
- 不要用情绪化标签替代分析。
- 不要开头抛概念,正文不断换概念。
## Final check
- 核心观点能否用一句话复述?
- 每个论点是否有事实、案例或逻辑支撑?
- 是否处理了最强的反方意见?
- 语气是否有分寸,避免过度断言?
- 结尾是否完成判断,而不是只留下口号?

View File

@@ -1,45 +0,0 @@
# Genre Template: Personal Brand / 个人品牌大类
本文件是一级大类模板。适用:简历、自我介绍、作品集、朋友圈、个人主页、成长复盘等以可信呈现个人经历、能力和态度为核心的文档。读完本文件即可开始写作;当前不继续读取具体体裁文件。
## 写作思路
- 先判断目标读者:招聘方、面试官、合作方、潜在客户、同事、朋友,还是未来的自己。
- 个人表达要围绕“身份定位 → 关键经历 → 可验证成果 → 做事方式 → 下一步意图”。
- 求职 / 合作场景优先事实、证据、结果;社交 / 复盘场景可以保留个性和情绪。
- 不要夸大 title、职责、数据、奖项或项目归属不确定的成果用“参与 / 支持 / 负责部分”区分。
## 结构偏好
- **简历 / 个人主页**:定位摘要 → 核心能力 → 代表经历 / 项目 → 成果证据 → 联系 / 下一步。
- **自我介绍**:一句话身份 → 相关经历 → 能提供的价值 → 与当前场景的连接。
- **作品集**:选择标准 → 项目卡片 → 背景 / 角色 / 动作 / 结果 → 反思或方法论。
- **成长复盘**:阶段目标 → 关键事件 → 做对 / 做错 → 认知变化 → 下一阶段计划。
- **朋友圈 / 社交动态**:具体场景 → 真实感受 → 一个观点或行动,而不是履历堆砌。
## 风格
- 真实、具体、有选择。让事实说话,少用“优秀、负责、抗压、热爱”等自评词。
- 面向职业机会时克制清晰;面向社交表达时自然但不失分寸。
- 数据、案例和作品链接比泛泛形容更有说服力。
## 常用元素
- 项目表、能力矩阵、STAR / CAR 结构、时间线、作品卡、成果清单、下一步 checkbox。
- 作品集或个人主页可用 grid简历类避免过度装饰。
## 反模板
- 不要把经历包装到失真。
- 不要堆 buzzword缺少具体项目和结果。
- 不要把自我介绍写成长篇自传。
- 不要把成长复盘写成鸡汤或年度流水账。
- 不要在不同章节使用冲突的人设和定位。
## Final check
- 读者是否能快速理解“这个人是谁、能做什么、证据是什么”?
- 关键经历是否具体到角色、动作、结果?
- 是否避免夸大或模糊个人贡献?
- 语气是否符合场景,既不过度自夸也不过度谦虚?
- 下一步联系方式、作品入口或行动是否清楚?

View File

@@ -1,47 +0,0 @@
# Genre Template: Platform Native / 平台原生大类
本文件是一级大类模板。适用用户明确要求适配公众号、知乎、微博、B站、抖音、豆瓣、Newsletter 等平台形态、语气、结构或发布习惯的任务。读完本文件即可开始写作;当前不继续读取具体平台文件。
## 写作思路
- 先判断平台是否真的决定结构。若用户只是说“发到某平台”,但内容体裁更明确,优先内容体裁;只有明确要求平台写法时才使用本模板。
- 平台适配是“重组入口、节奏和互动方式”,不是改变事实。
- 保留核心信息:目标读者、事实材料、观点、证据和行动;再调整标题、开头、段落、互动和 CTA。
- 面向公众号、微信等外部平台粘贴 / 发布时,不使用飞书特有富 blockcallout / grid / 分栏等),避免粘贴后样式丢失或错乱;改用标准标题、段落、列表、引用和必要表格。
- 平台口吻不能替代内容质量;不要为了热度编造经历、冲突或数据。
## 结构偏好
- **公众号 / Newsletter**:标题 → 导语 / 问题 → 主体分节 → 总结 → 行动或订阅入口。
- **知乎**:问题复述 → 立场 / 结论 → 论据展开 → 反例 / 边界 → 总结。
- **微博 / 短帖**:一句核心观点 → 1-3 个支撑点 → 话题 / 互动。
- **短视频 / 口播**:前三秒钩子 → 场景 / 冲突 → 观点或步骤 → 转折 → 结尾行动。
- **小红书 / 豆瓣**:具体体验或清单 → 个人判断 → 适合 / 不适合 → 互动或收藏点。
## 风格
- 平台语气要适度:更口语、更有节奏,但不降低事实密度。
- 标题和开头可以强化钩子,但不能制造与正文不一致的期待。
- 长平台重逻辑和可信度,短平台重入口和记忆点。
## 常用元素
- 标题备选、开头钩子、分节小标题、互动问题、CTA、话题标签、脚本分镜表。
- 多平台改写时用 table 对比标题、开头、结构、CTA。
## 反模板
- 不要只把文章切短就叫平台适配。
- 不要为了“平台感”堆 emoji、网络梗和夸张词。
- 不要把专业内容改到事实失真。
- 不要忽略平台长度、互动入口和读者预期。
- 不要多个平台共用同一个标题和开头。
- 不要把飞书高亮块、分栏或复杂富 block 当作可迁移的平台排版。
## Final check
- 是否确认平台需求真的决定写法?
- 核心事实和观点是否保真?
- 标题、开头和 CTA 是否符合平台读者任务?
- 语气是否平台化但不过度表演?
- 是否保留了足够的信息密度和可验证性?

View File

@@ -1,65 +0,0 @@
# Genre Router: Report / 报告研究大类
本文件既是一级大类模板,也保留报告类二类 routing。适用行业报告、数据报告、调研报告、白皮书、商业分析、竞品分析等以证据、洞察和决策为核心的文档。
读取规则:先使用本文件完成大类判断;只有当用户意图明确命中下方二类,且需要更细结构时,才额外读取一个具体体裁文件。不要读取多个报告体裁文件。
## 写作思路
- 先确定报告任务:解释现状、发现问题、评估机会、支持决策、对外建立权威,还是沉淀研究。
- 区分事实、数据、样本、假设、洞察和建议;不要把相关性写成因果。
- 结论要前置,证据要可追溯,建议要说明取舍、风险和适用边界。
- 如果资料不足,先列出缺口和假设,不要填充看似完整但不可验证的结论。
## 默认结构
- Executive summary / 一句话结论
- 背景与问题定义
- 方法、样本或数据口径
- 关键发现 / 洞察
- 影响评估 / 机会与风险
- 建议动作 / 决策项
- 附录:数据、访谈、定义、来源
## 风格
- 客观、克制、证据驱动;少用营销语和未经验证的趋势判断。
- 标题要表达发现,而不是只写主题。例如“留存下滑主要来自新用户首周激活不足”优于“留存分析”。
- 建议必须能落到对象、动作、优先级或决策。
## 常用元素
- 指标表、样本说明、对比矩阵、风险雷达、假设清单、引用块、路线图、决策表。
- 数据或多对象对比优先 table趋势、流程、行业结构复杂时可用 whiteboard。
## 二类 routing
| 用户意图 / 信号词 | 读取文件 |
|---|---|
| 行业报告、调研报告、用户研究、市场研究、研究报告 | `research-report.md` |
| 数据报告、指标分析、经营数据、漏斗分析、仪表盘解读 | `data-report.md` |
| 白皮书、趋势报告、方法论报告、权威长报告 | `white-paper.md` |
| 商业分析、战略分析、竞品分析、商业计划分析 | `business-analysis.md` |
冲突规则:
- 基于访谈、资料、样本或文献形成洞察时,优先 `research-report.md`
- 核心证据是指标、时间序列、分布、漏斗或实验时,优先 `data-report.md`
- 面向外部传播、体系化观点和品牌权威时,优先 `white-paper.md`
- 面向商业决策、竞争格局、策略选项和资源取舍时,优先 `business-analysis.md`
## 反模板
- 不要只有宏观背景,没有明确问题和结论。
- 不要把资料摘要伪装成洞察。
- 不要只展示数据,不解释口径、原因和行动含义。
- 不要使用“趋势明显、空间巨大、潜力无限”等无证据判断。
- 不要给出建议却没有优先级、风险和验证方式。
## Final check
- 结论、证据、假设和建议是否分层清楚?
- 数据口径、样本和来源是否说明?
- 每个洞察是否能追溯到材料或逻辑?
- 建议是否明确对象、动作、优先级和风险?
- 是否只额外读取了一个命中的报告二类文件?

View File

@@ -1,70 +0,0 @@
# Genre Router: Workplace / 职场协作大类
本文件既是一级大类模板,也保留职场类二类 routing。适用周报、方案、纪要、复盘、PRD、技术文档、SOP、公文、正式协作文档等以协作、决策、执行和留档为核心的文档。
读取规则:先使用本文件完成大类判断;只有当用户意图明确命中下方二类,且需要更细结构时,才额外读取一个具体体裁文件。不要读取多个职场体裁文件。
## 写作思路
- 先判断文档要推动什么:同步进展、争取决策、分配责任、固化流程、解释技术、沉淀复盘,还是正式留档。
- 职场文档默认服务协作执行:结论、责任人、时间、状态、风险、依赖和下一步要清楚。
- 面向领导 / 决策者时前置结论和选项;面向执行者时前置目标、范围、步骤和验收;面向归档时前置口径和可追溯性。
- 改写已有职场文档时,保留原有事实、评论锚点、资源块和组织口径;不要为了“更完整”全文覆盖。
## 默认结构
- 目标 / 结论 / 待决策事项
- 背景或现状,只保留影响判断的信息
- 方案、进展、事实或流程主体
- 风险、依赖、待确认事项
- 行动项、责任人、时间点、验收标准
- 附录:材料、数据、链接、术语
## 风格
- 直接、克制、可执行;少用“赋能、抓手、闭环、沉淀、拉通”等空泛词。
- 标题表达动作和状态,例如“本周完成 XY 因依赖 Z 延后”优于“工作进展”。
- 对不确定事项明确标注 owner 和截止时间。
## 常用元素
- 状态表、RACI / owner 表、时间线、风险 callout、决策矩阵、checkbox、接口表、代码块、流程图。
- 排期、责任、指标、接口参数优先 table流程、架构、依赖复杂时可用 whiteboard。
## 二类 routing
| 用户意图 / 信号词 | 读取文件 |
|---|---|
| 备忘录、领导简报、决策简报、情况说明、内部分析摘要 | `memo-brief.md` |
| 周报、月报、进展同步、工作总结 | `weekly-report.md` |
| 方案、项目方案、工作方案、正式方案、跨团队协作方案 | `proposal.md` |
| 公司通知、制度规范、正式汇报、对外说明、内部正式文档 | `formal-doc.md` |
| 红头文件、公文、请示、批复、函、通报、主送机关、抄送机关 | `official-redhead.md` |
| 会议纪要、会议记录、议定事项、行动项 | `meeting-minutes.md` |
| 复盘、事故总结、项目总结、阶段总结 | `retrospective.md` |
| PRD、需求说明、用户故事、产品方案、功能设计 | `prd.md` |
| 架构、接口、API、部署、迁移、排障、技术设计 | `technical-doc.md` |
| SOP、操作手册、培训材料、入门指南、使用说明 | `sop-tutorial.md` |
冲突规则:
- 目标是快速帮助高层判断时,优先 `memo-brief.md`
- 目标是协作推进时,优先读能定义责任、时间、状态和下一步的体裁。
- 技术 / PRD / SOP 是职场文档的专门体裁,不要用普通方案覆盖。
- 明确公文、红头时读 `official-redhead.md`,不要用 `formal-doc.md`
## 反模板
- 不要默认套“背景 / 目标 / 方案 / 风险 / 下一步”。
- 不要只有愿景,没有 owner、时间和验收标准。
- 不要把风险写成笼统提醒,没有影响、概率和应对动作。
- 不要把技术文档写成汇报稿,或把汇报写成技术细节堆砌。
- 不要为了排版完整而压低关键信息的优先级。
## Final check
- 读者是否知道要判断、执行或确认什么?
- 责任人、时间点、状态、风险和下一步是否明确?
- 结构是否匹配场景,而不是套通用模板?
- 表格、callout、whiteboard 是否确实降低协作成本?
- 是否只额外读取了一个命中的职场二类文件?

View File

@@ -1,35 +0,0 @@
# Genre: Business Analysis / 商业分析
适用:商业分析、战略分析、竞品分析、商业计划分析、公司研究。
## Thinking guide
先确定商业问题:增长、利润、效率、竞争、组织、产品、渠道还是资本。用事实、模型和约束推导建议。
## Structure patterns
问题 → 背景 → 市场 / 用户 → 竞争格局 → 商业模型 → 关键判断 → 方案 / 建议 → 风险。
## Style
分析导向、结论前置、避免管理黑话。模型服务判断,不是装饰。
## Component bias
- 商业模型whiteboard
- 竞品对比table
- SWOT / 取舍grid / table
- 关键判断callout
## Avoid
- 套模型不回答问题。
- 只有观点没有证据。
- 忽略成本、约束和风险。
- 建议不可执行。
## Final check
- 商业问题是否明确?
- 判断是否有证据和模型支撑?
- 建议是否可执行?

View File

@@ -1,35 +0,0 @@
# Genre: Data Report / 数据报告
适用:数据报告、指标分析、经营数据、漏斗分析、实验结果、仪表盘解读。
## Thinking guide
先确认指标口径、时间范围、分群维度和业务问题。数据报告不是罗列数字,而是解释变化、定位原因、给出动作。
## Structure patterns
结论摘要 → 指标口径 → 总览 → 分维度分析 → 异常 / 归因 → 建议动作 → 限制。
## Style
准确、可复算、少形容词。所有关键数字都要有口径和时间范围。
## Component bias
- 指标表table
- 趋势图 / 漏斗whiteboard
- 异常点callout
- 行动项checkbox
## Avoid
- 只列数据,不解释意义。
- 口径不清。
- 把相关性写成因果。
- 选择性展示有利数据。
## Final check
- 指标口径是否清楚?
- 变化是否有解释?
- 建议是否能落到动作?

View File

@@ -1,45 +0,0 @@
# Genre: Formal Document / 企业正式文档
适用:企业或组织内部正式文档,如公司通知、制度规范、正式汇报、立项材料、正式方案、对外说明、跨团队协作文档。不适用于党政机关公文 / 红头文件;相关任务使用 `official-redhead.md`
## Thinking guide
先确认文档是否需要归档、追责或跨团队执行。正式文档优先事项、范围、责任、时间和口径,不追求个性化表达。
## Structure patterns
事项型:事项 → 范围 → 安排 → 时间 → 责任。决策型:结论 → 背景 → 对比 → 风险 → 待决策。制度型:目的 → 范围 → 规则 → 例外 → 执行。
## Style
准确、克制、明确责任边界。少用夸张修饰和网感标题。
默认优先连贯段落:去掉结构后仍能顺成一段话的内容,就写成段落;成行成列、多对象多字段的数据,才写成表格。标题层级只给真正章节,字段、方法、要点等“小标题 + 一两句话”的小项改用表格字段、标签行或“**加粗引导句** + 段落”。
标题和条款编号使用飞书原生自动编号,采用阿拉伯数字;简单并列项写成 `<ol><li seq="auto">...</li></ol>`不要手写“一、”“”“1.1”等编号前缀。编号只给真正并列或有顺序的章节 / 条款,不为每段凑编号。
列举只用于真正并列的具体事项;背景、现状、认识、分析、过渡、总结优先成段,不要每段一个 bullet。
## Component bias
- 时间 / 责任 / 范围table
- 风险 / 待确认:段落 / table仅内部协作稿必要时用 callout
- 方案对比table正式提交稿避免 grid
- 流程 / 里程碑table复杂内部方案才用 whiteboard
公文、法律、学术、申报、项目方案等严肃正式提交物默认不用 callout / grid / 分栏;需要强调时用加粗引导句或规范小标题。
## Avoid
- 开头背景过长。
- 缺少责任人、时间、范围。
- 为了正式感堆抽象词。
- 为了美观强行加组件。
- 手写编号前缀,或混用中文编号、阿拉伯小数编号和原生自动编号。
- 每个小项都编号、每段一个 bullet或为凑层级把短句升成标题。
## Final check
- 事项、范围、责任、时间是否明确?
- 结论和待决策是否前置?
- 是否适合归档和追责?

View File

@@ -1,35 +0,0 @@
# Genre: Meeting Minutes / 会议纪要
适用:会议纪要、会议记录、评审结论、项目例会、决策会、同步会。
## Thinking guide
先区分“已决议、待确认、个人观点、行动项”。会议纪要不是流水账,重点是结论、分歧、责任和下一步。
## Structure patterns
会议信息 → 背景 → 讨论要点 → 决议 → 行动项 → 风险 / 待确认。
## Style
中立、准确、面向行动。尽量保留关键原话和分歧依据。
## Component bias
- 会议信息table
- 决议callout / list
- 行动项checkbox / table
- 原话blockquote
## Avoid
- 只按发言顺序记录。
- 责任人和截止时间缺失。
- 把未确认观点写成决议。
- 删除关键分歧。
## Final check
- 决议、待确认、行动项是否分开?
- 每个行动项是否有负责人和时间?
- 关键分歧是否保留?

View File

@@ -1,39 +0,0 @@
# Genre: Memo / Briefing Note
适用:备忘录、领导简报、决策简报、情况说明、问题简报、内部分析摘要。目标是帮助读者快速掌握情况并做判断。
## Structure patterns
按任务选择结构:
- **决策简报**:一句话结论 → 背景 → 选项对比 → 推荐方案 → 风险 → 待决策问题。
- **情况说明**:事项概述 → 当前状态 → 影响范围 → 已采取措施 → 后续安排。
- **问题简报**:问题 → 证据 → 原因 → 影响 → 建议动作。
- **备忘录**To / From / Date / Subject → Purpose → Context → Discussion → Action / Recommendation。
## Style
短、准、直接。先给结论,再给依据。面向高层或跨团队读者时减少细枝末节,把关键判断、影响和动作前置。
## Component bias
- 结论 / 建议:短段落或 callout。
- 方案对比table。
- 风险和影响table / callout。
- 待决策问题checkbox / list。
- 背景材料:附录或折叠到后文,不要压住结论。
## Avoid
- 背景过长,读者看不到结论。
- 只有信息罗列,没有判断。
- 只说“建议推进”,没有选项和取舍。
- 风险轻描淡写或没有责任边界。
- 把简报写成完整报告。
## Final check
- 第一屏是否能读到结论或建议?
- 是否清楚说明需要读者做什么决定?
- 选项、风险、影响和下一步是否明确?
- 是否足够短,适合快速阅读?

View File

@@ -1,41 +0,0 @@
# Genre: Official Redhead / 公文红头
适用:党政机关、事业单位、国企或正式组织语境下的公文 / 红头文件,包括通知、通报、请示、批复、函、纪要、公告、通告、报告、意见等。
## Thinking guide
先判定文种,再按文种写;公文是强约束文书,不是“更正式的通知”。缺少发文机关、主送机关、文号、依据等信息时标注占位或待确认,不要伪造红头、印章、文号或法定依据。
## Structure patterns
通知:依据 → 事项 → 要求 → 时间。请示:缘由 → 请示事项 → 依据 → “妥否,请批示”。批复:依据 → 明确意见 → 执行要求。报告:情况 → 成效 → 问题 → 下一步,报告不得夹带请示。纪要:会议情况 → 会议明确 → 议定事项。
## Style
庄重、准确、规范、简洁。少用修饰,不用 emoji、网感表达和花哨排版。
标题层级只给章节;“小标题 + 一两句话”的字段、方法、要点改写成段落、条款或加粗引导句。公文常用“一、→(一)→ 1. →1”体例不要“一、”配“1.1 / 2.1”,不要跳号或跳级。
“一是 / 二是”只用于列具体问题或措施。背景、现状、认识、分析、过渡、总结一律优先成段;不要每节都写成“一是 / 二是”,也不要把每段都拆成 bullet。
## Component bias
- 正文:段落 + 条款
- 责任分工 / 附件table
- 议定事项table / checkbox
- 原则上少用 callout / grid / 颜色
## Avoid
- 文种混乱。
- 报告夹带请示。
- 伪造红头、印章、文号、签发人或依据。
- 口语化、营销化。
- 为凑公文感滥用“一是 / 二是”或层层编号。
- 使用 callout、grid、分栏、颜色等平台化排版替代规范层级和段落。
## Final check
- 文种是否正确?
- 发文对象、事项、依据、期限、责任是否明确?
- 是否避免伪造格式要素?

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