Compare commits

...

31 Commits

Author SHA1 Message Date
zhengzhijie.j
c7d7cb03eb feat(sheets): add --skip-filter to cells-get 2026-07-28 19:12:56 +08:00
xiongyuanwen-byted
ca7135f582 fix(sheets): correct +dim-insert --inherit-style side mapping, drop redundant none
The flag name was inverted relative to actual behavior, and `none` was redundant.

- Map --inherit-style onto the modify_sheet_structure backend so the name
  matches behavior (verified on a live sheet): `before` inherits the preceding
  row/column (side=after, position-1); `after` inherits the following
  (side=before). Insertion always lands before --position.
- Warn only when `before` is used at the first row/column (no preceding
  dimension to copy from); `after` has no such edge.
- Drop the `none` enum value: it was identical to omitting the flag and
  misleadingly implied "no inheritance" (the backend always copies a
  neighbour). Omitting the flag inherits the following row/column; `none` is
  now rejected by enum validation. Clear formats afterwards for a blank one.
- Regenerate flag-defs, update tests and the skill reference.
2026-07-24 19:14:18 +08:00
xiongyuanwen-byted
7b58ba1b1d feat(sheets): batch-update contract hardening and style-vocabulary acceptance (#2028)
* feat(sheets): harden +batch-update sub-op contract (P0-1/2/3/5)

Eval-driven fixes for the top +batch-update error clusters (137 errors
across 7 eval batches, attribution in the optimization plan doc):

- Reject unknown sub-op input keys with did-you-mean + full key contract
  instead of silently ignoring them (silent ignore surfaced as misleading
  'missing required flag' errors — the largest cluster, ~35 hits).
  Habitual spellings are rewritten in place: camelCase -> snake_case,
  commandFlagAliases (new: size -> width/height on the resize pair, the
  pre-July vocabulary and the --styles protocol spelling, 15+ hits),
  single-entry ranges unwraps onto range.
- Aggregate per-op validation errors into one pass (each op's first
  error) instead of fail-fast first-error-only; single-error batches
  keep the standalone-shaped error (contract tests unchanged).
- Precheck cells matrix vs range locally: empty cells (prescribes
  +cells-clear) and row/column count mismatches no longer reach the
  server mid-batch.
- Correct the atomicity story: execution is fail-fast without rollback
  (verified against live batches; docs promised a rollback that does
  not happen). Partial-failure errors now spell out that succeeded
  operations stay applied and prescribe resending only the failed
  tail, preventing double-apply on retry.

* feat(cli): accept @file payload reads from the system temp dir (P0-6)

Agents stage generated payloads (batch operations JSON, CSV) in /tmp as
a matter of course; the relative-to-cwd-only policy pushed every
--operations @/tmp/ops.json through an extra python/stdin round trip
(recurring friction cluster in eval traces).

Scope is deliberately narrow: a new SafeTempAbsInputPath accepts an
absolute READ path only when it resolves (symlinks included) under the
canonical os.TempDir(), and only the @file expansion in
cmdutil.ReadInputFile uses it. SafeInputPath stays strict — uploads,
drive sync and the CI quality gates treat 'absolute paths rejected' as
a load-bearing invariant. Absolute paths outside the temp dir now get
a prescriptive error naming all three options (relative path, temp-dir
path, stdin).

* feat(sheets): add +styles-put, +dim-delete --ranges, freeze in --styles

Batch-B of the +batch-update overhaul (attribution: ~73% of real batch
calls were pure formatting finishers hand-built as operations arrays).

- +styles-put: declarative visual spec for existing spreadsheets.
  Reuses the workbook-create/table-put --styles parser (identical
  vocabulary and aggregate-all-issues errors), expands client-side into
  ONE atomic batch_update per spec: cell_merges -> cell_styles ->
  row_sizes -> col_sizes -> freeze. Style stamps are safe to re-run.
  Verified live: 6/6 sub-ops applied, frozen rows / merges / row
  heights confirmed by read-back.
- freeze section added to the shared --styles pipeline ({rows, cols}),
  so +workbook-create and +table-put gain it too — closes the one gap
  that still forced a separate +dim-freeze call.
- +dim-delete --ranges: scattered row/column ranges in one atomic
  batch, ordered DESCENDING so earlier deletions never shift later
  indexes (the recurring failure of hand-built dim-delete batches);
  same-dimension and non-overlap enforced, nesting inside
  +batch-update rejected with a prescription.
- +cells-batch-set-style enters phase-1 deprecation: kept working,
  docs point at +styles-put, an in-band note steers new usage.

Skill docs regenerated from sheet-skill-spec (new
lark-sheets-styles-put reference, three-way dispatch in guideline 6,
fail-fast-no-rollback wording).

* feat(sheets): accept height/width as one-way aliases for size in --styles row/col_sizes

size stays the canonical dimension key: it keeps row_sizes and col_sizes
items shape-uniform (the array name already carries the dimension), it is
what shipped with workbook-create/table-put and what models demonstrably
converge on, and it matches the dimension-neutral precedent of comparable
APIs. The Excel-vocabulary words are accepted silently only where
unambiguous — height inside row_sizes, width inside col_sizes; the wrong
dimension's word gets a targeted error instead of a rewrite, and giving
both size and the alias is rejected. Shared parser, so +workbook-create /
+table-put / +styles-put all gain it.

* chore(sheets): sync skill docs — +cells-batch-set-style fully exits the skill surface

Regenerated from sheet-skill-spec: the deprecated command no longer
appears anywhere in SKILL.md or the references (multi-range styling
routes to +styles-put); its flag-defs entry is untouched, so the command
and --help keep working for compatibility callers, with the in-band
supersedence note steering them to +styles-put.

* fix(sheets): forgive habitual vocabulary on the --styles payload path

07-20 rerun attribution: the batch-update dispatch worked (calls 105->25,
errors 21->8; +styles-put adopted by 16/35 tasks) but +styles-put itself
hit a 56% stateful error rate — the redesign moved traffic from the flag
path onto the payload path, and the round-2 forgiveness layers (key
aliases, enum-value canonicalization) only existed on the flag path.
Both dominant clusters are fixed in the shared styles pipeline, so
+styles-put / +table-put / +workbook-create / typed --cells all gain it:

- border family folding (largest cluster, up to 88 issues in one retry):
  borders/border objects, border_top..right objects, border_style/color/
  weight scalars, and flattened border_<side>_<attr> keys all fold into
  the canonical nested border_styles; border_style:"thin" reads as a
  thin solid line (weight vocabulary in the style slot).
- enum VALUE canonicalization inside cell_styles (~10 server-side
  round trips: vertical_alignment "center" -> "middle"), sourced from
  the +cells-set-style flag enums; off-enum values now fail client-side
  with a did-you-mean instead of failing the whole batch server-side.
- wrap family: wrap_text/text_wrap -> word_wrap, boolean -> enum.
- bare-string cell_merges entries read as {range, merge_type:all}.
- fore_color gets a prescription (openpyxl fgColor is the FILL color;
  a silent pick could color the wrong thing).

Replayed the eval-failing payload shapes live: 2/2 applied.

* feat(sheets): resize type optional in --styles; acceptance-surface contract tests

- {range, size} in row/col_sizes now means a pixel resize (type stays
  for standard/auto) — the payload path matches the flag path, where
  --width never required --type.
- Two closure tests turn the --styles acceptance surface into a locked
  contract instead of open-ended patching:
  * vocabulary parity — every +cells-set-style flag (iterated from
    flag-defs) must be accepted verbatim by the payload path, so a
    future flag can never again ship without payload-path support;
  * prior corpus — every model spelling observed across the 07-08..07-20
    eval batches must either normalize to canonical or produce a
    targeted prescription; silent ignoring and bare rejection both fail
    the suite. New eval finding -> add a corpus row -> fix -> locked.

Skill docs regenerated: the border shorthand ({style,weight,color} on
all four sides) is now the teaching form, border_styles demoted to
per-side differences; resize examples drop the type ceremony.

* fix(sheets): close the 07-21 rerun residuals — full-form thin, range coalescing, typed-cells style key

Valid rerun (skill injection verified at ~31k chars/task): +styles-put
stateful error rate fell 56.5% -> 34.3% and batch-update stayed at its
post-dispatch low. Three residual clusters, all closed:

- weight vocabulary in the FULL nested form's style slot
  (border_styles.<side>.style:"thin" — 8 tasks, the dominant residual;
  the earlier rewrite only covered the shorthand scalar path). Now
  normalized in expandBorderAllShorthand, the single border touchpoint
  shared by the flag, typed-cells and styles-payload paths.
- per-row specs blowing the 100-op cap (184/203/861-op expansions):
  coalesceStyleStamps fuses identical-style entries into rectangles
  (vertical fixpoint merge on same column span, horizontal on same row
  span) before the cap — a declarative spec describes intent, execution
  shape is the CLI's to optimize. Cap message now also routes
  alternating-row banding to +cond-format-create.
- typed --cells habitual keys (recurring server-side 900015206 in both
  reruns): cells[][].style object rewrites to cell_styles;
  cells[][].type gets a prescription instead of a server round trip.
  The content-in-styles message is also neutral now (was
  workbook-create-specific).

Corpus + coalescing + typed-cells tests added; live replay: full-form
thin accepted and 3 same-style rows fused, all applied.

* refactor(sheets): give the style-vocabulary acceptance layer its own home

Pure mechanical move, zero behavior change (locked by the acceptance
contract tests). The acceptance layer had grown as an accretion across
helpers.go and lark_sheet_workbook.go — deliberate design (one canonical
form + wide acceptance, per the divergent-priors evidence), accidental
placement.

style_vocab.go now holds the whole subsystem — flag-path style builders,
key aliases, enum-value canonicalization, border folding/normalization,
typed-cells cell-object rewrites — under a header that states the design
contract: rewrites must be unambiguous, ambiguity prescribes, silence
and bare rejection are both bugs, closure is enforced by the parity +
prior-corpus tests. helpers.go shrinks back to generic plumbing
(818 -> 542 lines).

The other two acceptance surfaces keep their own homes: cobra flag
ergonomics in flag_ergonomics.go, batch sub-op key vocabulary in
batch_op_dispatch.go.

* fix(sheets): enforce the cells-vs-range match on single-cell ranges too

The precheck deliberately skipped bare single-cell ranges when server
behavior was unverified; the 07-21 rerun supplied the evidence (12 rows
against range "A1" failing server-side with row count 1) — +cells-set
has no anchor semantics, the strict match applies everywhere. Corpus
updated accordingly.

* feat(sheets): make +csv-get --range optional — omitted reads the whole sheet

The tool requires a range but clips past-grid references and reports the
clip in actual_range, so the CLI sends an over-wide whole-columns range
(A:ZZZ) when --range is omitted: one call reads the entire sheet, no
workbook-info pre-flight to size it first. Eval evidence: 'required
flag(s) range not set' was the most-missed required flag on +csv-get
(4 tasks in the 07-21 batch) — the models' intent was always 'read it
all'. Blank --range now means the same as omitting it.

Skill docs updated (quick-reference row, read-data full-read example,
flag desc); round-3 backlog item A5.

* feat(sheets): add editing rule #10 — never fabricate missing values

补齐 / 扩展 / 按原表格式续填时,查不到或无法确定的值一律留空 +
备注注明,禁止用推算 / 估算 / 凭空数据充数;原表已示范缺失值写法时照抄。

Synced from sheet-skill-spec (SoT).

* fix(sheets): accept side-first border word order and wrap_strategy

07-21 evening batch (first run carrying the previous fixes — thin
full-form / style/type keys / csv-get range all at zero): the corpus
loop caught the next spelling permutations. bottom_border /
bottom_border_style (side-first word order, alongside the border_bottom
family already folded) and wrap_strategy (the Google Sheets API word)
now normalize; three corpus rows lock them.

* feat(sheets): universal did-you-mean on unknown style fields; formalize the silent-alias admission bar

The alias table was drifting toward per-permutation entries with fast-
falling marginal value (first aliases covered 15+ errors each, the last
ones 1-2). The asymmetry that matters: rejection is universal, aliases
are per-word. So:

- unknown style fields now reject with did-you-mean + the full canonical
  field list (this error had neither — the actual reason word-order
  permutations turned into multi-issue retry loops). Any future
  permutation costs one self-healing retry, zero new code, and corrects
  the whole session (silent aliases never correct the model in-session).
- the acceptance-layer contract now states the admission bar for silent
  aliases: real external vocabularies only (Excel/openpyxl, CSS, Google
  Sheets API — a finite set), recurring across batches or ≥3 tasks,
  zero ambiguity. Permutations go to the universal rejection. Existing
  permutation aliases are grandfathered.

* feat(sheets): +cells-set --writes — scattered multi-region writes in one atomic call

The last compressible batch-update scenario: eval traces show 'fix all
broken formulas across ranges/sheets' (~6 calls / 3 tasks per batch)
still hand-assembled as +batch-update operations arrays. --writes takes
[{sheet_name|sheet_id, range, cells}, ...] (up to 100 items, cross-sheet)
and fans it into ONE atomic batch_update of set_cell_range ops.

Design decisions:
- the sheet selector LIVES IN EACH ITEM — no top-level fallback, no
  precedence table; same convention models already learned from
  +batch-update sub-ops and +styles-put items. A top-level
  --sheet-name/--sheet-id with --writes is rejected with the fix.
- every item runs the exact standalone pipeline via a per-item flag
  view: key vocabulary (camelCase, aliases, did-you-mean), the style
  acceptance layer for inline cell_styles, matrix precheck, schema
  validation; item errors aggregate so one retry fixes all.
- XOR with --range/--cells/--copy-to-range; top-level --allow-overwrite
  propagates to items that don't override it.
- nesting inside +batch-update rejected (expands into its own batch).
- predictable prior handled: --styles on +cells-set now hints the
  layering (range-level styling -> +styles-put; per-cell styles ride in
  the cells objects) instead of a bare unknown-flag error.

Live smoke: value + formula regions in one call, 2/2 applied.
Expected effect: batch-update calls drop another ~30% to its
heterogeneous-atomic-chain steady state.

* chore(sheets): bump lark-sheets skill version to 3.1.0

Version roll-up for the batch-update optimization series: cells-set
--writes, universal did-you-mean on style fields, border word-order and
wrap_strategy acceptance, editing rule #10, and optional +csv-get
--range.
2026-07-24 13:18:03 +08:00
anunwu-byted
765b097d44 Merge pull request #2027 from larksuite/feat/error-schema-hints
Feat/error schema hints
2026-07-23 16:40:10 +08:00
wuyanchun.anunwu
4a5e2c519a feat(sheets): 校验失败全量收集报错(一次报出所有错误)
为什么:fail-fast 单错报错导致「挤牙膏」修复回路——修一处、重试、
撞下一处。评测实测(turbo 三批 247 次失败)约 32% 的失败轮次是
挤牙膏,其中 local→local 类(39 次)本可一次报出。

怎么改:
- validateAgainstSchema 重构为 collectSchemaErrors 收集器版:命中
  错误后继续遍历,全部问题一次报出(每条带各自的教学信息)
- translateBatchOperations 同步做 op 级聚合:多个坏 op 一条报错
  编号列出,不再 fail-fast 在第一个
- 三条护栏:单错误输出逐字不变(向后兼容);显示 cap 5 条 + 收集
  到 6 即全树短路(病态大数组不爆炸);类型错节点不下钻、oneOf
  用一次性探测器(防级联噪音/误报泄漏)

验证:gofmt/vet 干净,go test -count=1 全过(既有测试零改动,
新增 5 个聚合测试:多错编号、cap 截断、oneOf 不泄漏、batch 双 op
聚合、单 op 保持原文)。
2026-07-20 21:11:00 +08:00
xiongyuanwen-byted
67fc870582 feat(sheets): add --output-path for full-read file offload on cells/csv/table-get
Reads are capped by max_chars (default 500000; the backend tool also truncates
at ~50000 when unset). Add --output-path to +cells-get / +csv-get / +table-get:
when set, the result is written to a cwd-relative path as JSON and the char cap
is lifted to unbounded, so a large sheet lands on disk in full instead of being
clipped for stdout.

+table-get previously never sent max_chars, so it silently dropped rows past the
backend ~50000 default with no signal. It now takes --max-chars (default 500000,
sent explicitly) and surfaces truncated / truncation_warning when the read is
clipped, steering callers to --output-path for a lossless full read.
2026-07-20 11:26:46 +08:00
xiongyuanwen-byted
af8e027269 feat(sheets): support --include truncation on +cells-get
Map the new `truncation` value in --include to include_truncation_info on
the get_cell_ranges tool input, so +cells-get can return per-cell
isRowTruncated / isColTruncated. Flag metadata and reference synced from
sheet-skill-spec; flag_defs_gen.go regenerated.
2026-07-20 11:26:46 +08:00
xiongyuanwen-byted
2efadec335 feat(sheets): cut agent error rate and --help lookups (#1911)
## Background

Round 2 of eval-driven sheets optimization, rebased onto the latest `feat/lark-sheets-develop` (`8897196d`).

## Changes

- **feat(sheets): cut agent error rate and --help lookups (eval round 2)** — targets the top failure modes from round 2 evals, reducing agent error rate and the number of `--help` lookups.
- **chore(sheets): sync skill docs and flag data from sheet-skill-spec** — syncs skill docs and flag data from sheet-skill-spec.

## Notes

- During rebase, the "import mislabeled .xls workbooks by sniffing content" fix already existed on the target branch (identical patch-id), so it was auto-skipped — no duplicate.
- The target branch was force-rewritten and advanced in the meantime; the two new commits were cleanly replayed onto the new tip via `--onto` with no conflicts. One hunk touching the `--type` description in `lark-sheets-workbook.md` was auto-dropped because upstream already has the same end state — no content lost.
2026-07-20 11:26:46 +08:00
zhanghuanxu
44514ad114 fix(slides): detect visual elements outside canvas 2026-07-19 21:45:42 +08:00
liangshuo-1
4a56748bfa chore: release v1.0.72 (#1943) 2026-07-17 19:43:46 +08:00
luozhixiong01
0b6faa01bf ci: deduplicate PR runs and serialize live E2E (#1888)
* ci: deduplicate PR runs and serialize live E2E

* ci: preserve live E2E cleanup on supersession

* ci: harden live E2E supersession check

* ci: gate live E2E on dry-run planning

Make the dry-run result a hard prerequisite for live E2E so skip-mode changes never acquire the repository-wide slot. This intentionally trades one full dry-run duration of live startup latency for lower contention on the exclusive queue.

* ci: bound dry-run E2E planning

The dry-run job is now a hard prerequisite for live E2E. Bound its
execution so a stalled planning job cannot delay a PR verdict for the
default six-hour job limit.

* test: tighten live E2E supersession contract
2026-07-17 19:37:48 +08:00
LightsDancer
1efe2dfb33 feat(approval): support approval event consumption (#1924)
Register approval.instance.status_changed_v4 and approval.task.status_changed_v4 with custom flattened schemas and user-auth pre-consume subscription setup.

Handle approval subscription_type as optional multi-value pre-registration metadata: omitted values register both involved and managed relations, explicit values can be single, comma-separated, or JSON array, and consumers do not unsubscribe on exit.

Report partial approval subscription registration failures with registered and failed relation context while preserving the underlying typed error classification.

Document approval event output fields and subscription semantics, and refresh approval skill references from API metadata.
2026-07-17 18:38:29 +08:00
luozhixiong01
767386cb57 fix: stabilize drive delete E2E terminal-state checks (#1939)
* fix: converge drive delete workflow test on terminal state

* fix: narrow drive delete tolerance to the verified transient

* test: lock the delete failure guard with a subprocess contract test

* test: lock task-result and retry-exhaustion failure boundaries
2026-07-17 18:00:38 +08:00
luozhixiong01
e71c76155e test: fix drive cover download retries (#1934) 2026-07-17 17:53:01 +08:00
luozhixiong01
c363acf94e test: use tri-state wiki node identity in delete verification (#1931)
A get_node success response may omit data.node/node_token (the field is
optional), so a missing token must not be read as proof of deletion.
Classify the response as same / different / unknown: only a different
non-empty node_token proves the original node is gone (move-to-drive),
while an unknown identity keeps polling in isWikiNodeDeleted and still
attempts deletion in deleteWikiNodeAndVerify instead of leaking nodes.
2026-07-17 17:52:12 +08:00
zhanghuanxu
05285bb696 feat(slides): report resolved table size mismatches 2026-07-17 17:29:39 +08:00
zhanghuanxu
4c0f93bd6a feat(slides):lint table out of canvas 2026-07-17 17:29:39 +08:00
Yuxuan Zhao
76ebd49382 test: stabilize live e2e auth retries (#1904)
* test: stabilize live e2e auth retries

* fix(e2e): scope shared tenant credentials
2026-07-17 17:20:45 +08:00
zhengzhijiej-tech
6c14c425fc docs(sheets): use English placeholder in table-get guidance (#1936) 2026-07-17 17:01:22 +08:00
calendar-assistant
27df16d3b2 fix(vc): don't fail +detail for in-progress meetings (#1930)
An ongoing meeting has no minute/note yet, so the recording lookup in
+detail returned an unclassified error that was surfaced as a hard error,
making the whole command exit 1 / ok:false even though meeting.get had
already succeeded.

Detect the in-progress state up front (same start/end heuristic as
+meeting-events, reading raw timestamps) and skip the recording call,
returning the meeting metadata with an informational hint instead of an
error. Recording failures for ended meetings are likewise degraded to a
hint rather than failing the command.

Also note in the vc-agent skill that sending an in-meeting message only
needs meeting_id and must not pre-fetch +detail / +recording / +notes.
2026-07-17 15:54:26 +08:00
zgz2048
47dc003601 docs: document base field default values (#1500)
* docs: document base field default values

* docs(base): update field default value schema
2026-07-17 15:17:01 +08:00
zhanghuanxu
4e0a6a988c docs(slides): document table dimensions 2026-07-17 11:36:53 +08:00
liangshuo-1
708196040a chore: release v1.0.71 (#1919) 2026-07-16 20:34:43 +08:00
yballul-bytedance
65586577a3 feat(drive): add secure label support and clarify comment location API (#1913)
Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-16 18:09:38 +08:00
wangweiming-01
be1f3621de perf(drive): optimize drive +delete workflow (#1909) 2026-07-16 16:21:37 +08:00
chenxingyang1019
65998a21e3 docs(apps): add platform SQL authoring guide to the db-execute skill (#1912)
* docs(apps): add platform SQL authoring guide to the db-execute skill

Aligns the lark-cli apps +db-execute skill with the Miaoda platform's
SQL constraints (the same dataloom backend the sandbox miaoda-sql skill
targets), so agents writing SQL via the CLI don't get server-rejected or
build tables that misbehave. Previously the skill covered only the command
contract with zero SQL-content guidance.

Adds a "平台 SQL 规范" section to lark-apps-db-execute.md covering:
- Platform-forbidden SQL (DATABASE/SCHEMA/USER/ROLE/OWNED) that hard-rejects
- CREATE TABLE template: 4 audit columns + RLS + 4 default policies
- user_profile compound type (ROW()::user_profile, (field).user_id, index)
- Audit column names and semantics
- DDL rules: IF NOT EXISTS support matrix; pre-check online rows before
  adding constraints, split by UNIQUE / tighten-to-NOT-NULL / new-NOT-NULL-column
- SELECT / DML safety rules and common PostgreSQL pitfalls

Sandbox-specific bits (miaoda command names, generate_image, test-user
list, schema.ts codegen, string error codes) are intentionally excluded.
Wires pointers from SKILL.md routing and lark-apps-db.md.

* docs(apps): address review nits on the db-execute SQL guide

- Drop the IF NOT EXISTS support matrix (redundant / conflicted with the
  extension-allowlist note in the callout).
- Use `orders` instead of the built-in composite type `user_profile` as the
  bare-table-name example, which was misleading.
- Remove the "user_profile PRIMARY KEY" suggestion for person tables (the
  composite carries mutable fields; a uuid PK is the right default).
2026-07-16 16:05:26 +08:00
zhouyue-bytedance
d5afe3f705 fix(base): improve dashboard shortcut guidance (#1787)
* fix(base): improve dashboard shortcut guidance

* docs(base): refine dashboard funnel guidance

* docs(base): drop redundant block-get audit tip

The 'do not audit every block after creation' hint duplicates the
create-then-suppress-get guidance already in lark-base-dashboard.md,
so remove it from the +dashboard-block-get tips to keep them focused.

* test(base): drop stale block-get audit tip assertion

Commit 778da63a removed the 'do not audit every block' tip from the
+dashboard-block-get source as redundant but left the matching
assertion in TestBaseDashboardHelpGuidesAgents, breaking the unit
test. Remove the stale assertion to realign the test with the tips.

* docs(base): clarify when NOT to use helper table for dashboard blocks

* fix(base): defer record-list --json to framework shorthand (align with main)

* fix(base): reject non-string dashboard sort.order instead of silently defaulting to asc

* docs(base): fix reversed cumulative-funnel direction (suffix sum + assumptions)

* docs(base): scope dashboard-arrange to explicit request or fresh new dashboard

* test(base): pin missing sort.order behavior; clarify --no-validate is raw pass-through

* docs(base): show real CLI envelope {ok,identity,data} for get-data and data-query outputs
2026-07-16 15:10:15 +08:00
linchao5102
baf6050f8e feat(apps): add role management shortcuts (#1881) 2026-07-16 14:09:41 +08:00
zhaojunlin0405
a6bc81596a ci: add L4 plugin-integration and sidecar-integration CI jobs (#1840) 2026-07-16 13:47:11 +08:00
liujinkun2025
7f43b7ed5d feat: add wiki move-to-drive shortcut (#1869)
* feat: add wiki move-to-drive shortcut
2026-07-16 11:00:28 +08:00
wuyanchun.anunwu
5fb70d326a feat(sheets): 校验失败报错内联 schema 提示(报错即教学)
为什么:豆包 Excel Agent 案例中模型 7 次参数错误,报错只说"错了"不说
"怎么改对",模型反复试错并静默降级交付(5 张饼图丢数据标签)。

怎么改:
- strict unexpected-property 报错附该节点合法 key 列表(cap 15 截断)
  + did-you-mean(复用 internal/suggest)
- required-missing 报错附缺失字段的 type/description/enum 一行提示
- 深层 type mismatch 报错附该字段 enum/description 后缀
- +batch-update 顶层 --sheet-id/--sheet-name(含下划线拼写)特判,
  直接指明 per-op locator 契约,不给误导性 fuzzy 建议
- batch sub-op input 拒收 cell_styles/styles/cell_merges 包裹结构,
  报错教学扁平 flags 写法
- 守护测试锁定 wrappedSubOpInputKeys 与 batchOpDispatch 的互斥假设

约束:不改 legacy 报错措辞前缀、保留 --print-schema 指针、不动宽松
AdditionalProperties 设计(内嵌 schema 当前无 strict 节点,该路径为预置)。

验证:gofmt -l 无输出、go vet 干净、go test -count=1 ./shortcuts/sheets/
./internal/suggest/ 全部通过。
2026-07-15 18:13:22 +08:00
194 changed files with 20986 additions and 992 deletions

View File

@@ -1,4 +1,5 @@
name: CI
run-name: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
on:
push:
@@ -8,6 +9,12 @@ on:
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
# PR metadata edits can retrigger full CI for the same head. Keep only the
# newest run for a pull request; push and manual runs use a unique run ID.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
actions: read
@@ -47,6 +54,34 @@ jobs:
exit 1
fi
plugin-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
# No fetch_meta: the git-archive clean tree must embed only the
# committed meta_data stub (reproduces the bare-module customer state).
- name: Run plugin-integration L4 tests
run: go test -count=1 -timeout=15m ./tests/plugin_e2e/...
sidecar-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Run sidecar tag build + HMAC round-trip
run: make sidecar-test
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate
@@ -176,7 +211,11 @@ jobs:
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: |
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
# tests/ holds only L3/L4 suites (cli_e2e, plugin_e2e, sidecar_e2e) that
# have dedicated jobs; exclude the whole subtree so none of them runs a
# second time here — and, crucially, so an observe-only suite's failure
# can never block merges through coverage's spot in the results loop.
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
@@ -263,6 +302,11 @@ jobs:
e2e-dry-run:
needs: [unit-test, lint, script-test, deterministic-gate]
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
mode: ${{ steps.e2e_domains.outputs.mode }}
reason: ${{ steps.e2e_domains.outputs.reason }}
live_packages: ${{ steps.e2e_domains.outputs.live_packages }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -276,6 +320,23 @@ jobs:
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Validate CLI E2E domain outputs
env:
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
case "$E2E_MODE" in
skip)
[ -z "$E2E_LIVE_PACKAGES" ] || { echo "::error::Skip mode must not resolve live packages"; exit 1; }
;;
full|subset)
[ -n "$E2E_LIVE_PACKAGES" ] || { echo "::error::No live packages resolved for mode $E2E_MODE"; exit 1; }
;;
*)
echo "::error::Invalid CLI E2E mode: $E2E_MODE"
exit 1
;;
esac
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
@@ -309,16 +370,22 @@ jobs:
fi
e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != '' }}
runs-on: ubuntu-latest
timeout-minutes: 30
# Live E2E uses one repository-wide execution slot.
concurrency:
group: lark-cli-e2e-live
cancel-in-progress: false
queue: max
permissions:
actions: read
contents: read
checks: write
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
LARKSUITE_CLI_BRAND: feishu
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -329,31 +396,68 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
id: build_cli
run: make build
- name: Configure bot credentials
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
- name: Prepare shared live E2E tenant token
id: live_e2e_tat
env:
LARKSUITE_CLI_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
run: node scripts/fetch_e2e_tat.js
- name: Run CLI E2E tests
# Keep an active Go test alive so t.Cleanup can finish. A queued stale
# run is rejected below before it can start live E2E.
if: ${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
RUN_ID: ${{ github.run_id }}
RUN_NUMBER: ${{ github.run_number }}
RUN_GENERATION: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ needs.e2e-dry-run.outputs.mode }}
E2E_REASON: ${{ needs.e2e-dry-run.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ needs.e2e-dry-run.outputs.live_packages }}
E2E_TENANT_AUTH_FILE: ${{ steps.live_e2e_tat.outputs.path }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
if [ "$EVENT_NAME" = "pull_request" ]; then
workflow_id="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" --jq '.workflow_id')"
newer_runs="$(
gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs" \
-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100 |
jq -r --arg repository "$REPOSITORY" --arg generation "$RUN_GENERATION" --argjson run_number "$RUN_NUMBER" \
'.workflow_runs[] | select(.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number) | .id'
)"
if [ -n "$newer_runs" ]; then
echo "::error::Superseded before live E2E started by newer workflow run(s): $newer_runs"
exit 1
fi
fi
if [ -z "${E2E_TENANT_AUTH_FILE:-}" ] || [ ! -f "$E2E_TENANT_AUTH_FILE" ]; then
echo "::error::Missing shared live E2E tenant token file"
exit 1
fi
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
export TEST_TENANT_ACCESS_TOKEN="$(cat "$E2E_TENANT_AUTH_FILE")"
rm -f "$E2E_TENANT_AUTH_FILE"
if ! LARKSUITE_CLI_APP_ID="$TEST_BOT1_APP_ID" \
LARKSUITE_CLI_TENANT_ACCESS_TOKEN="$TEST_TENANT_ACCESS_TOKEN" \
./lark-cli whoami --as bot | node -e '
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { input += chunk; });
process.stdin.on("end", () => {
const result = JSON.parse(input);
if (result.identity !== "bot" || result.available !== true || result.tokenStatus !== "ready") process.exit(1);
});
'; then
echo "::error::Tenant credential preflight failed"
exit 1
fi
echo "Tenant credential preflight succeeded"
packages="$E2E_LIVE_PACKAGES"
if [ -z "$packages" ]; then
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
@@ -363,7 +467,7 @@ jobs:
echo "Live CLI E2E packages: $packages"
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
@@ -416,7 +520,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -436,10 +540,19 @@ jobs:
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
# license-header on push) are OK.
# Legitimately skipped jobs (deadcode on push, e2e-live when not
# needed or on a fork, license-header on push) are OK.
#
# plugin-integration and sidecar-integration are intentionally NOT
# in this loop yet: they run on every PR and their status is shown
# in the table above, but a failure is observe-only (non-blocking)
# during the initial soak. Graduation to required is tracked in
# https://github.com/larksuite/cli/issues/1894 (criteria: 4
# consecutive weeks with zero false positives).
FAILED=0
for result in \
"${{ needs.fast-gate.result }}" \

View File

@@ -2,6 +2,52 @@
All notable changes to this project will be documented in this file.
## [v1.0.72] - 2026-07-17
### Features
- **slides**: lint table out of canvas
- **slides**: report resolved table size mismatches
- **approval**: support approval event consumption (#1924)
### Bug Fixes
- **vc**: don't fail +detail for in-progress meetings (#1930)
- stabilize drive delete E2E terminal-state checks (#1939)
### Documentation
- **slides**: document table dimensions
- document base field default values (#1500)
- **sheets**: use English placeholder in table-get guidance (#1936)
### Tests
- stabilize live e2e auth retries (#1904)
- use tri-state wiki node identity in delete verification (#1931)
- fix drive cover download retries (#1934)
## [v1.0.71] - 2026-07-16
### Features
- add wiki move-to-drive shortcut (#1869)
- **apps**: add role management shortcuts (#1881)
- **drive**: add secure label support and clarify comment location API (#1913)
### Bug Fixes
- **base**: improve dashboard shortcut guidance (#1787)
### Documentation
- **apps**: add platform SQL authoring guide to the db-execute skill (#1912)
### Misc
- add L4 plugin-integration and sidecar-integration CI jobs (#1840)
- **drive**: optimize drive +delete workflow (#1909)
## [v1.0.70] - 2026-07-15
### Features
@@ -1506,6 +1552,8 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68

View File

@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
all: test
@@ -51,7 +51,7 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta
@@ -64,6 +64,9 @@ examples-build:
go build ./extension/platform/examples/audit-observer
go build ./extension/platform/examples/readonly-policy
# ./tests/... includes tests/plugin_e2e, which builds ~20 customer-fork
# binaries (~1 min warm; a cold module cache also downloads via GOPROXY).
# Deliberate: local `make test` exercises the L4 plugin contract by default.
integration-test: build
go test -v -count=1 ./tests/...
@@ -105,6 +108,14 @@ uninstall:
clean:
rm -f $(BINARY)
# sidecar-test compiles and runs the authsidecar* build-tagged code that the
# default CI matrix never sees (they carry //go:build tags).
sidecar-test:
go build -tags authsidecar -o /dev/null .
go test $(RACE_FLAG) -count=1 -tags authsidecar ./extension/credential/sidecar/ ./extension/transport/sidecar/ ./internal/cmdutil/
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.

View File

@@ -17,6 +17,8 @@ import (
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
@@ -36,6 +38,8 @@ func TestRunList_TextOutput(t *testing.T) {
out := stdout.String()
for _, want := range []string{
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
@@ -90,6 +94,8 @@ func TestRunList_JSONOutput(t *testing.T) {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
for _, want := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {

View File

@@ -19,6 +19,29 @@ import (
_ "github.com/larksuite/cli/events"
)
type approvalSchemaJSONPayload struct {
JQRootPath string `json:"jq_root_path"`
AuthTypes []string `json:"auth_types"`
Scopes []string `json:"scopes"`
Params []approvalSchemaJSONParam `json:"params"`
ResolvedOutputSchema approvalSchemaJSONResolvedSchema `json:"resolved_output_schema"`
}
type approvalSchemaJSONParam struct {
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
SubscriptionKey bool `json:"subscription_key"`
}
type approvalSchemaJSONResolvedSchema struct {
Properties map[string]approvalSchemaJSONProperty `json:"properties"`
}
type approvalSchemaJSONProperty struct {
Format string `json:"format"`
}
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
@@ -158,6 +181,60 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
}
}
func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) {
tests := []struct {
key string
scope string
}{
{"approval.instance.status_changed_v4", "approval:instance:read"},
{"approval.task.status_changed_v4", "approval:task:read"},
}
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, tc.key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload approvalSchemaJSONPayload
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload.JQRootPath != "." {
t.Errorf("jq_root_path = %v, want .", payload.JQRootPath)
}
if got := payload.AuthTypes; !reflect.DeepEqual(got, []string{"user"}) {
t.Errorf("auth_types = %#v, want user", got)
}
if got := payload.Scopes; !reflect.DeepEqual(got, []string{tc.scope}) {
t.Errorf("scopes = %#v, want %s", got, tc.scope)
}
if len(payload.Params) != 1 {
t.Fatalf("params = %#v, want one subscription_type param", payload.Params)
}
param := payload.Params[0]
if param.Name != "subscription_type" || param.Type != "multi" || param.Required || param.SubscriptionKey {
t.Fatalf("subscription_type param = %#v, want optional multi non-subscription-key param", param)
}
props := payload.ResolvedOutputSchema.Properties
for _, field := range []string{"type", "event_id", "timestamp", "approval_code", "instance_code", "status", "operate_time"} {
if _, ok := props[field]; !ok {
t.Errorf("approval schema missing flat field %q: %+v", field, props)
}
}
if _, ok := props["event"]; ok {
t.Errorf("approval Custom schema should be flat, got envelope field event: %+v", props)
}
if got := props["operate_time"].Format; got != "timestamp_ms" {
t.Errorf("operate_time format = %v, want timestamp_ms", got)
}
})
}
}
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
type approvalEventType string
type approvalSubscriptionPath string
type approvalSubscriptionConfig struct {
eventType approvalEventType
subscribePath approvalSubscriptionPath
}
func approvalSubscriptionPreConsume(cfg approvalSubscriptionConfig) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
eventType := string(cfg.eventType)
subscribePath := string(cfg.subscribePath)
subscriptionTypes, err := approvalSubscriptionTypes(eventType, params)
if err != nil {
return nil, err
}
registered := make([]string, 0, len(subscriptionTypes))
for _, subscriptionType := range subscriptionTypes {
body := map[string]string{"subscription_type": subscriptionType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, approvalSubscriptionRegistrationError(eventType, registered, subscriptionType, err)
}
registered = append(registered, subscriptionType)
}
// Approval subscriptions are durable user-auth relations. Consuming events
// should not cancel that relation when this local process exits.
return nil, nil
}
}
func approvalSubscriptionTypes(eventType string, params map[string]string) ([]string, error) {
raw := strings.TrimSpace(params["subscription_type"])
if raw == "" {
return append([]string(nil), approvalAllSubscriptionTypes...), nil
}
values, err := parseApprovalSubscriptionTypeValues(raw)
if err != nil {
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
}
selected := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
switch value {
case approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged:
selected[value] = true
default:
return nil, invalidApprovalSubscriptionTypeError(eventType, value)
}
}
result := make([]string, 0, len(selected))
for _, value := range approvalAllSubscriptionTypes {
if selected[value] {
result = append(result, value)
}
}
if len(result) == 0 {
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
}
return result, nil
}
func parseApprovalSubscriptionTypeValues(raw string) ([]string, error) {
if strings.HasPrefix(raw, "[") {
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, err
}
return values, nil
}
return strings.Split(raw, ","), nil
}
func approvalSubscriptionRegistrationError(eventType string, registered []string, failed string, err error) error {
if err == nil {
return nil
}
msg := fmt.Sprintf(
"approval subscription pre-consume failed for EventKey %s: failed subscription_type %s",
eventType,
failed,
)
hint := fmt.Sprintf(
"no approval subscription relation was registered for EventKey %s; fix the cause and retry",
eventType,
)
if len(registered) > 0 {
msg = fmt.Sprintf(
"approval subscription pre-consume partially completed for EventKey %s: registered subscription_type(s) [%s], failed subscription_type %s",
eventType,
strings.Join(registered, ", "),
failed,
)
hint = fmt.Sprintf(
"server-side approval subscription relation(s) already registered for EventKey %s: %s; after fixing the cause, retry with --param subscription_type=%s to register the failed relation",
eventType,
strings.Join(registered, ", "),
failed,
)
}
if p, ok := errs.ProblemOf(err); ok {
if upstream := strings.TrimSpace(p.Message); upstream != "" {
p.Message = msg + ": " + upstream
} else {
p.Message = msg
}
if upstreamHint := strings.TrimSpace(p.Hint); upstreamHint != "" {
p.Hint = upstreamHint + "\n" + hint
} else {
p.Hint = hint
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).
WithHint("%s", hint).
WithCause(err)
}
func invalidApprovalSubscriptionTypeError(eventType, value string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid subscription_type for EventKey %s: %q", eventType, value).
WithParam("--param").
WithHint("omit subscription_type to register both approval subscription relations, or pass --param subscription_type=%s, --param subscription_type=%s, or --param subscription_type=%s,%s; run `lark-cli event schema %s` for details",
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
eventType)
}

179
events/approval/register.go Normal file
View File

@@ -0,0 +1,179 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package approval registers Approval-domain EventKeys.
package approval
import (
"context"
"encoding/json"
"reflect"
"github.com/larksuite/cli/internal/event"
)
const (
eventTypeApprovalInstanceStatusChangedV4 = "approval.instance.status_changed_v4"
eventTypeApprovalTaskStatusChangedV4 = "approval.task.status_changed_v4"
pathApprovalInstancesSubscription = "/open-apis/approval/v4/instances/subscription"
pathApprovalTasksSubscription = "/open-apis/approval/v4/tasks/subscription"
approvalSubscriptionTypeInvolved = "INVOLVED_APPROVAL"
approvalSubscriptionTypeManaged = "MANAGED_APPROVAL"
)
var approvalAllSubscriptionTypes = []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
}
// Keys returns all Approval-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeApprovalInstanceStatusChangedV4,
DisplayName: "Approval instance status changed",
Description: "Triggered after an approval instance status becomes visible to the requester or approval participants",
EventType: eventTypeApprovalInstanceStatusChangedV4,
Params: approvalSubscriptionParams(),
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{})},
},
Process: processApprovalInstanceStatusChanged,
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
}),
Scopes: []string{"approval:instance:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeApprovalInstanceStatusChangedV4},
},
{
Key: eventTypeApprovalTaskStatusChangedV4,
DisplayName: "Approval task status changed",
Description: "Triggered after an approval task status becomes visible to the requester or task approver",
EventType: eventTypeApprovalTaskStatusChangedV4,
Params: approvalSubscriptionParams(),
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{})},
},
Process: processApprovalTaskStatusChanged,
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
}),
Scopes: []string{"approval:task:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeApprovalTaskStatusChangedV4},
},
}
}
func approvalSubscriptionParams() []event.ParamDef {
return []event.ParamDef{
{
Name: "subscription_type",
Type: event.ParamMulti,
Description: "Approval subscription relation type(s) to register for the current authorized user. Omit to register both involved and managed approval relations.",
Values: []event.ParamValue{
{
Value: approvalSubscriptionTypeInvolved,
Desc: "Receive events where the current user is the approval requester or approver.",
},
{
Value: approvalSubscriptionTypeManaged,
Desc: "Receive events under approval definitions managed by the current user.",
},
},
},
}
}
func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
if raw == nil {
return nil, nil
}
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
ApprovalCode string `json:"approval_code"`
InstanceCode string `json:"instance_code"`
ExternalID string `json:"external_id"`
Status string `json:"status"`
OperateTime string `json:"operate_time"`
StartUser *ApprovalUserID `json:"start_user"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
out := &ApprovalInstanceStatusChangedV4Output{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
ApprovalCode: envelope.Event.ApprovalCode,
InstanceCode: envelope.Event.InstanceCode,
ExternalID: envelope.Event.ExternalID,
Status: envelope.Event.Status,
OperateTime: envelope.Event.OperateTime,
StartUser: envelope.Event.StartUser,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}
func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
if raw == nil {
return nil, nil
}
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
ApprovalCode string `json:"approval_code"`
InstanceCode string `json:"instance_code"`
TaskID string `json:"task_id"`
ExternalID string `json:"external_id"`
TaskExternalID string `json:"task_external_id"`
AssignedUser *ApprovalUserID `json:"assigned_user"`
Status string `json:"status"`
OperateTime string `json:"operate_time"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
out := &ApprovalTaskStatusChangedV4Output{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
ApprovalCode: envelope.Event.ApprovalCode,
InstanceCode: envelope.Event.InstanceCode,
TaskID: envelope.Event.TaskID,
ExternalID: envelope.Event.ExternalID,
TaskExternalID: envelope.Event.TaskExternalID,
AssignedUser: envelope.Event.AssignedUser,
Status: envelope.Event.Status,
OperateTime: envelope.Event.OperateTime,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -0,0 +1,654 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
type recordedCall struct {
method string
path string
body interface{}
}
type fakeAPIClient struct {
calls []recordedCall
err error
errOnCall int
}
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
if f.err != nil && (f.errOnCall == 0 || f.errOnCall == len(f.calls)) {
return nil, f.err
}
return json.RawMessage(`{}`), nil
}
func TestKeysApprovalMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 2 {
t.Fatalf("len(Keys()) = %d, want 2", len(keys))
}
tests := []struct {
key string
scope string
schemaType reflect.Type
subscribe string
}{
{
key: eventTypeApprovalInstanceStatusChangedV4,
scope: "approval:instance:read",
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
subscribe: pathApprovalInstancesSubscription,
},
{
key: eventTypeApprovalTaskStatusChangedV4,
scope: "approval:task:read",
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
subscribe: pathApprovalTasksSubscription,
},
}
byKey := make(map[string]event.KeyDefinition, len(keys))
for _, def := range keys {
byKey[def.Key] = def
}
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
def, ok := byKey[tc.key]
if !ok {
t.Fatalf("missing key %s", tc.key)
}
if def.EventType != tc.key {
t.Errorf("EventType = %q, want %q", def.EventType, tc.key)
}
if def.Schema.Custom == nil || def.Schema.Custom.Type != tc.schemaType {
t.Fatalf("Custom schema Type = %v, want %v", def.Schema.Custom, tc.schemaType)
}
if def.Schema.Native != nil {
t.Fatal("approval events must use Custom schema while SDK event types are not exported")
}
if def.Process == nil {
t.Fatal("Process must flatten raw V2 envelopes")
}
if def.PreConsume == nil {
t.Fatal("PreConsume must subscribe approval user-auth events")
}
if !reflect.DeepEqual(def.Scopes, []string{tc.scope}) {
t.Errorf("Scopes = %#v, want %q", def.Scopes, tc.scope)
}
if !reflect.DeepEqual(def.AuthTypes, []string{"user"}) {
t.Errorf("AuthTypes = %#v, want user", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{tc.key}) {
t.Errorf("RequiredConsoleEvents = %#v, want %q", def.RequiredConsoleEvents, tc.key)
}
assertSubscriptionParam(t, def.Params)
})
}
}
func assertSubscriptionParam(t *testing.T, params []event.ParamDef) {
t.Helper()
if len(params) != 1 {
t.Fatalf("len(params) = %d, want 1", len(params))
}
p := params[0]
if p.Name != "subscription_type" || p.Type != event.ParamMulti || p.Required || p.SubscriptionKey {
t.Fatalf("subscription_type param = %+v, want optional multi non-subscription-key param", p)
}
got := map[string]string{}
for _, v := range p.Values {
got[v.Value] = v.Desc
}
for _, want := range []string{approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged} {
if got[want] == "" {
t.Errorf("subscription_type value %q missing or empty desc; values=%+v", want, p.Values)
}
}
}
type reflectedApprovalSchema struct {
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
}
type reflectedApprovalSchemaProperty struct {
Format string `json:"format"`
Enum []string `json:"enum"`
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
}
func TestApprovalSchemasAnnotations(t *testing.T) {
tests := []struct {
name string
schemaType reflect.Type
eventType string
statusValues []string
userField string
}{
{
name: "instance",
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
eventType: eventTypeApprovalInstanceStatusChangedV4,
statusValues: []string{"PENDING", "APPROVED", "REJECTED", "CANCELED", "DELETED", "REVERTED", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
userField: "start_user",
},
{
name: "task",
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
eventType: eventTypeApprovalTaskStatusChangedV4,
statusValues: []string{"REVERTED", "PENDING", "APPROVED", "REJECTED", "TRANSFERRED", "ROLLBACK", "DONE", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
userField: "assigned_user",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var schema reflectedApprovalSchema
if err := json.Unmarshal(schemas.FromType(tc.schemaType), &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
props := schema.Properties
eventTypeEnum := props["type"].Enum
if len(eventTypeEnum) != 1 || eventTypeEnum[0] != tc.eventType {
t.Fatalf("type enum = %v, want %s", eventTypeEnum, tc.eventType)
}
if got := props["timestamp"].Format; got != "timestamp_ms" {
t.Errorf("timestamp format = %v, want timestamp_ms", got)
}
assertEnumContains(t, props["status"].Enum, tc.statusValues)
if got := props["operate_time"].Format; got != "timestamp_ms" {
t.Errorf("event.operate_time format = %v, want timestamp_ms", got)
}
userProps := props[tc.userField].Properties
if got := userProps["open_id"].Format; got != "open_id" {
t.Errorf("%s.open_id format = %v, want open_id", tc.userField, got)
}
if got := userProps["union_id"].Format; got != "union_id" {
t.Errorf("%s.union_id format = %v, want union_id", tc.userField, got)
}
if got := userProps["user_id"].Format; got != "user_id" {
t.Errorf("%s.user_id format = %v, want user_id", tc.userField, got)
}
})
}
}
func assertEnumContains(t *testing.T, raw []string, wants []string) {
t.Helper()
got := make(map[string]bool, len(raw))
for _, v := range raw {
got[v] = true
}
for _, want := range wants {
if !got[want] {
t.Errorf("enum missing %q; enum=%v", want, raw)
}
}
}
func TestApprovalPreConsumeRegistersSubscriptionTypesWithoutCleanup(t *testing.T) {
tests := []struct {
name string
eventType string
subscribePath string
params map[string]string
wantTypes []string
}{
{
name: "instance omitted subscription_type registers both",
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
{
name: "task explicit single managed",
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
params: map[string]string{"subscription_type": approvalSubscriptionTypeManaged},
wantTypes: []string{approvalSubscriptionTypeManaged},
},
{
name: "task comma separated multi canonicalizes and deduplicates",
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
params: map[string]string{
"subscription_type": approvalSubscriptionTypeManaged + "," + approvalSubscriptionTypeInvolved + "," + approvalSubscriptionTypeManaged,
},
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
{
name: "instance json array multi",
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
params: map[string]string{
"subscription_type": `["MANAGED_APPROVAL","INVOLVED_APPROVAL"]`,
},
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: approvalEventType(tc.eventType),
subscribePath: approvalSubscriptionPath(tc.subscribePath),
})
rt := &fakeAPIClient{}
cleanup, err := pc(context.Background(), rt, tc.params)
if err != nil {
t.Fatalf("PreConsume returned error: %v", err)
}
if cleanup != nil {
t.Fatal("cleanup must be nil; approval consume must not unsubscribe on exit")
}
assertSubscriptionCalls(t, rt.calls, tc.subscribePath, tc.wantTypes)
})
}
}
func assertSubscriptionCalls(t *testing.T, got []recordedCall, wantPath string, wantTypes []string) {
t.Helper()
if len(got) != len(wantTypes) {
t.Fatalf("calls after pre-consume = %d, want %d; calls=%+v", len(got), len(wantTypes), got)
}
for i, wantType := range wantTypes {
assertCall(t, got[i], "POST", wantPath, map[string]string{"subscription_type": wantType})
}
}
func assertCall(t *testing.T, got recordedCall, wantMethod, wantPath string, wantBody interface{}) {
t.Helper()
if got.method != wantMethod {
t.Errorf("method = %q, want %q", got.method, wantMethod)
}
if got.path != wantPath {
t.Errorf("path = %q, want %q", got.path, wantPath)
}
if !reflect.DeepEqual(got.body, wantBody) {
t.Errorf("body = %#v, want %#v", got.body, wantBody)
}
}
func TestApprovalPreConsumeValidationErrors(t *testing.T) {
t.Run("nil runtime", func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
})
_, err := pc(context.Background(), nil, map[string]string{"subscription_type": approvalSubscriptionTypeInvolved})
if err == nil {
t.Fatal("expected nil runtime error")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryInternal {
t.Fatalf("err = %T/%v, want typed internal error", err, err)
}
})
for _, raw := range []string{"BAD", "[]", `["INVOLVED_APPROVAL",3]`} {
t.Run("invalid subscription type "+raw, func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
})
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{"subscription_type": raw})
if err == nil {
t.Fatal("expected invalid subscription_type error")
}
if cleanup != nil {
t.Fatal("cleanup must be nil on validation error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T/%v, want *errs.ValidationError", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
t.Errorf("subtype/param = %s/%q, want invalid_argument/--param", ve.Subtype, ve.Param)
}
if ve.Hint == "" {
t.Error("invalid subscription_type should carry a hint")
}
})
}
t.Run("partial registration failure reports registered and failed relation types", func(t *testing.T) {
upstream := errs.NewAPIError(errs.SubtypeServerError, "approval subscription API failed")
rt := &fakeAPIClient{err: upstream, errOnCall: 2}
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
})
cleanup, err := pc(context.Background(), rt, map[string]string{})
if err == nil {
t.Fatal("expected partial registration error")
}
if cleanup != nil {
t.Fatal("cleanup must be nil on registration error")
}
assertSubscriptionCalls(t, rt.calls, pathApprovalTasksSubscription, []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
t.Fatalf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
}
for _, want := range []string{
"registered subscription_type(s) [INVOLVED_APPROVAL]",
"failed subscription_type MANAGED_APPROVAL",
} {
if !strings.Contains(p.Message, want) {
t.Errorf("partial error message missing %q: %q", want, p.Message)
}
}
for _, want := range []string{
"already registered",
"--param subscription_type=MANAGED_APPROVAL",
} {
if !strings.Contains(p.Hint, want) {
t.Errorf("partial error hint missing %q: %q", want, p.Hint)
}
}
})
}
func TestApprovalSubscriptionRegistrationErrorVariants(t *testing.T) {
t.Run("nil error", func(t *testing.T) {
if err := approvalSubscriptionRegistrationError(eventTypeApprovalTaskStatusChangedV4, nil, approvalSubscriptionTypeInvolved, nil); err != nil {
t.Fatalf("nil cause returned error: %v", err)
}
})
t.Run("typed error with existing hint and empty message", func(t *testing.T) {
upstream := errs.NewAPIError(errs.SubtypeServerError, "").WithHint("retry later")
err := approvalSubscriptionRegistrationError(
eventTypeApprovalTaskStatusChangedV4,
nil,
approvalSubscriptionTypeInvolved,
upstream,
)
if err != upstream {
t.Fatalf("typed error should be annotated in place; got %T/%v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if !strings.Contains(p.Message, "failed subscription_type INVOLVED_APPROVAL") {
t.Errorf("message missing failed relation: %q", p.Message)
}
for _, want := range []string{"retry later", "no approval subscription relation was registered"} {
if !strings.Contains(p.Hint, want) {
t.Errorf("hint missing %q: %q", want, p.Hint)
}
}
})
t.Run("untyped error is wrapped with retry context", func(t *testing.T) {
cause := errors.New("transport closed")
err := approvalSubscriptionRegistrationError(
eventTypeApprovalTaskStatusChangedV4,
nil,
approvalSubscriptionTypeInvolved,
cause,
)
if !errors.Is(err, cause) {
t.Fatalf("wrapped error should preserve cause; got %T/%v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeSDKError {
t.Fatalf("category/subtype = %s/%s, want internal/sdk_error", p.Category, p.Subtype)
}
if !strings.Contains(p.Hint, "no approval subscription relation was registered") {
t.Errorf("hint missing no-registration context: %q", p.Hint)
}
})
}
func TestProcessApprovalInstanceStatusChanged(t *testing.T) {
out := runApprovalInstanceStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_instance_001",
"event_type": "approval.instance.status_changed_v4",
"create_time": "1710000000000"
},
"event": {
"approval_code": "approval_code_001",
"instance_code": "instance_code_001",
"external_id": "external_001",
"status": "PENDING",
"operate_time": "1666079207003",
"start_user": {
"open_id": "ou_start",
"union_id": "on_start",
"user_id": "user_start"
}
}
}`)
if out.Type != eventTypeApprovalInstanceStatusChangedV4 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalInstanceStatusChangedV4)
}
if out.EventID != "evt_approval_instance_001" || out.Timestamp != "1710000000000" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.ApprovalCode != "approval_code_001" || out.InstanceCode != "instance_code_001" {
t.Errorf("approval/instance code = %q/%q", out.ApprovalCode, out.InstanceCode)
}
if out.ExternalID != "external_001" || out.Status != "PENDING" || out.OperateTime != "1666079207003" {
t.Errorf("external/status/operate_time = %q/%q/%q", out.ExternalID, out.Status, out.OperateTime)
}
if out.StartUser == nil || out.StartUser.OpenID != "ou_start" || out.StartUser.UnionID != "on_start" || out.StartUser.UserID != "user_start" {
t.Fatalf("StartUser = %+v, want full user ids", out.StartUser)
}
}
func TestProcessApprovalTaskStatusChanged(t *testing.T) {
out := runApprovalTaskStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_task_001",
"event_type": "approval.task.status_changed_v4",
"create_time": "1710000000001"
},
"event": {
"approval_code": "approval_code_002",
"instance_code": "instance_code_002",
"task_id": "task_001",
"external_id": "external_002",
"task_external_id": "task_external_001",
"status": "APPROVED",
"operate_time": "1666079207004",
"assigned_user": {
"open_id": "ou_assignee",
"union_id": "on_assignee",
"user_id": "user_assignee"
}
}
}`)
if out.Type != eventTypeApprovalTaskStatusChangedV4 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalTaskStatusChangedV4)
}
if out.EventID != "evt_approval_task_001" || out.Timestamp != "1710000000001" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.ApprovalCode != "approval_code_002" || out.InstanceCode != "instance_code_002" || out.TaskID != "task_001" {
t.Errorf("approval/instance/task = %q/%q/%q", out.ApprovalCode, out.InstanceCode, out.TaskID)
}
if out.ExternalID != "external_002" || out.TaskExternalID != "task_external_001" || out.Status != "APPROVED" || out.OperateTime != "1666079207004" {
t.Errorf("external/task_external/status/operate_time = %q/%q/%q/%q", out.ExternalID, out.TaskExternalID, out.Status, out.OperateTime)
}
if out.AssignedUser == nil || out.AssignedUser.OpenID != "ou_assignee" || out.AssignedUser.UnionID != "on_assignee" || out.AssignedUser.UserID != "user_assignee" {
t.Fatalf("AssignedUser = %+v, want full user ids", out.AssignedUser)
}
}
func TestProcessApprovalStatusChangedUsesRawEventTypeFallback(t *testing.T) {
instance := runApprovalInstanceStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_instance_fallback",
"create_time": "1710000000002"
},
"event": {
"approval_code": "approval_code_fallback",
"instance_code": "instance_code_fallback",
"status": "APPROVED",
"operate_time": "1666079207005"
}
}`)
if instance.Type != eventTypeApprovalInstanceStatusChangedV4 {
t.Errorf("instance Type fallback = %q, want %q", instance.Type, eventTypeApprovalInstanceStatusChangedV4)
}
task := runApprovalTaskStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_task_fallback",
"create_time": "1710000000003"
},
"event": {
"approval_code": "approval_code_fallback",
"instance_code": "instance_code_fallback",
"task_id": "task_fallback",
"status": "DONE",
"operate_time": "1666079207006"
}
}`)
if task.Type != eventTypeApprovalTaskStatusChangedV4 {
t.Errorf("task Type fallback = %q, want %q", task.Type, eventTypeApprovalTaskStatusChangedV4)
}
}
func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) {
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"instance", eventTypeApprovalInstanceStatusChangedV4, processApprovalInstanceStatusChanged},
{"task", eventTypeApprovalTaskStatusChangedV4, processApprovalTaskStatusChanged},
} {
t.Run(tc.name, func(t *testing.T) {
raw := &event.RawEvent{
EventType: tc.eventType,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := tc.process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
})
}
}
func TestProcessApprovalStatusChangedNilRaw(t *testing.T) {
for _, tc := range []struct {
name string
process event.ProcessFunc
}{
{"instance", processApprovalInstanceStatusChanged},
{"task", processApprovalTaskStatusChanged},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.process(context.Background(), nil, nil, nil)
if err != nil {
t.Fatalf("Process nil raw returned error: %v", err)
}
if got != nil {
t.Fatalf("Process nil raw output = %s, want nil", string(got))
}
})
}
}
func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInstanceStatusChangedV4Output {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeApprovalInstanceStatusChangedV4,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processApprovalInstanceStatusChanged(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process returned error: %v", err)
}
var out ApprovalInstanceStatusChangedV4Output
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid instance JSON: %v\nraw=%s", err, string(got))
}
return out
}
func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStatusChangedV4Output {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeApprovalTaskStatusChangedV4,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processApprovalTaskStatusChanged(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process returned error: %v", err)
}
var out ApprovalTaskStatusChangedV4Output
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid task JSON: %v\nraw=%s", err, string(got))
}
return out
}
func TestApprovalKeysRegisterCleanly(t *testing.T) {
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
}
for _, def := range Keys() {
event.RegisterKey(def)
}
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}
}
var _ event.APIClient = (*fakeAPIClient)(nil)

42
events/approval/types.go Normal file
View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
// ApprovalUserID identifies a user in the three Lark ID formats included by
// approval status-change events.
type ApprovalUserID struct {
OpenID string `json:"open_id,omitempty" desc:"User open_id; prefixed with ou_" kind:"open_id"`
UnionID string `json:"union_id,omitempty" desc:"User union_id" kind:"union_id"`
UserID string `json:"user_id,omitempty" desc:"User id within the tenant" kind:"user_id"`
}
// ApprovalInstanceStatusChangedV4Output is the flattened shape for
// approval.instance.status_changed_v4.
type ApprovalInstanceStatusChangedV4Output struct {
Type string `json:"type" desc:"Event type; always approval.instance.status_changed_v4" enum:"approval.instance.status_changed_v4"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval instance id; present only for third-party approvals"`
Status string `json:"status,omitempty" desc:"Approval instance status" enum:"PENDING,APPROVED,REJECTED,CANCELED,DELETED,REVERTED,OVERTIME_CLOSE,OVERTIME_RECOVER"`
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
StartUser *ApprovalUserID `json:"start_user,omitempty" desc:"Approval instance starter; omitted when unavailable"`
}
// ApprovalTaskStatusChangedV4Output is the flattened shape for
// approval.task.status_changed_v4.
type ApprovalTaskStatusChangedV4Output struct {
Type string `json:"type" desc:"Event type; always approval.task.status_changed_v4" enum:"approval.task.status_changed_v4"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
TaskID string `json:"task_id,omitempty" desc:"Approval task id"`
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval external id; present only for third-party approvals"`
TaskExternalID string `json:"task_external_id,omitempty" desc:"Third-party approval task external id; present only when emitted by the upstream service"`
AssignedUser *ApprovalUserID `json:"assigned_user,omitempty" desc:"Task assignee or operator user ids; omitted for automatic flows without an operator"`
Status string `json:"status,omitempty" desc:"Approval task status" enum:"REVERTED,PENDING,APPROVED,REJECTED,TRANSFERRED,ROLLBACK,DONE,OVERTIME_CLOSE,OVERTIME_RECOVER"`
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
}

View File

@@ -5,6 +5,7 @@
package events
import (
"github.com/larksuite/cli/events/approval"
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/task"
@@ -16,6 +17,7 @@ import (
// Mail is intentionally omitted in this phase.
func init() {
all := [][]event.KeyDefinition{
approval.Keys(),
im.Keys(),
minutes.Keys(),
task.Keys(),

View File

@@ -4,6 +4,10 @@
package cmdutil
import (
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
)
@@ -14,12 +18,75 @@ import (
// with --yes.
//
// action identifies the operation for the agent (e.g. "mail +send",
// "drive.files.delete"). The envelope does not carry a pre-built retry
// command: agents already know their original invocation and only need to
// append --yes per the hint, which keeps the protocol free of shell-quoting
// pitfalls.
// "drive.files.delete"). When the original invocation can be re-run safely,
// the hint carries the complete retry command with --yes appended — eval
// traces show agents always self-heal by appending --yes, so handing them
// the exact line saves the reconstruction step. The retry line is omitted
// (falling back to the plain hint) when any argument reads stdin (a bare "-",
// as its own token or bundled onto a flag as --flag=-, whose piped data a
// bare re-run would not reproduce) or when the rendered command would be
// unreasonably long to echo back.
func RequireConfirmation(action string) error {
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action).
WithHint("add --yes to confirm")
err := errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action)
if retry := retryCommandWithYes(os.Args); retry != "" {
return err.WithHint("add --yes to confirm; re-run: %s", retry)
}
return err.WithHint("add --yes to confirm")
}
// retryCommandMaxLen caps the rendered retry command: past this, echoing the
// full invocation back (e.g. a +batch-update with a large inline JSON)
// costs more context than it saves.
const retryCommandMaxLen = 300
// retryCommandWithYes renders args as a shell-safe command line with --yes
// appended, or "" when a safe rendering isn't possible (see
// RequireConfirmation).
func retryCommandWithYes(args []string) string {
if len(args) == 0 {
return ""
}
parts := make([]string, 0, len(args)+1)
parts = append(parts, filepath.Base(args[0]))
for _, a := range args[1:] {
if argReadsStdin(a) {
return ""
}
parts = append(parts, shellQuoteArg(a))
}
parts = append(parts, "--yes")
line := strings.Join(parts, " ")
if len(line) > retryCommandMaxLen {
return ""
}
return line
}
// argReadsStdin reports whether an argument makes a flag read from stdin — the
// portable bare "-" value, whether passed as its own token (--flag -) or
// bundled onto the flag (--flag=- / -f=-). Piped stdin is one-shot data a bare
// re-run cannot reproduce, so any such argument suppresses the retry line.
func argReadsStdin(a string) bool {
if a == "-" {
return true
}
if strings.HasPrefix(a, "-") {
if i := strings.IndexByte(a, '='); i >= 0 && a[i+1:] == "-" {
return true
}
}
return false
}
// shellQuoteArg single-quotes an argument when it contains any character a
// POSIX shell could interpret, so the retry line is copy-paste safe.
func shellQuoteArg(s string) string {
if s == "" {
return "''"
}
if !strings.ContainsAny(s, " \t\n\"'\\$`!*?[](){}<>|&;#~") {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

View File

@@ -35,8 +35,11 @@ func TestRequireConfirmation_TypedShape(t *testing.T) {
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
}
if cre.Hint != "add --yes to confirm" {
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
// The hint may additionally carry a re-run line composed from the live
// os.Args (environment-dependent under `go test`), but the add-yes
// contract always leads.
if !strings.HasPrefix(cre.Hint, "add --yes to confirm") {
t.Errorf("Hint = %q, want prefix 'add --yes to confirm'", cre.Hint)
}
if cre.Risk != errs.RiskHighRiskWrite {
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
@@ -61,8 +64,8 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
t.Fatalf("unmarshal: %v", err)
}
// No fix_command field leaks into the envelope: the protocol avoids
// shell-quoting hazards by delegating retry to agent-side logic.
// No fix_command field leaks into the envelope: the retry line lives in
// the free-text hint only; the typed protocol stays action-only.
if _, has := back["fix_command"]; has {
t.Errorf("unexpected fix_command present in JSON: %s", raw)
}
@@ -78,3 +81,46 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
t.Errorf("unexpected upgraded_by present in JSON: %s", raw)
}
}
// TestRetryCommandWithYes pins the retry-line contract: shell-safe quoting,
// basename argv[0], and the two omission guards (stdin args, oversized
// commands).
func TestRetryCommandWithYes(t *testing.T) {
t.Run("quotes what needs quoting and appends --yes", func(t *testing.T) {
got := retryCommandWithYes([]string{
"/usr/local/bin/lark-cli", "sheets", "+cells-clear",
"--url", "https://x.feishu.cn/sheets/tok",
"--range", "A1:B2", "--sheet-name", "第 1 班",
})
want := `lark-cli sheets +cells-clear --url https://x.feishu.cn/sheets/tok --range A1:B2 --sheet-name '第 1 班' --yes`
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
t.Run("single quotes inside args survive", func(t *testing.T) {
got := retryCommandWithYes([]string{"lark-cli", "x", "--title", "it's"})
if !strings.Contains(got, `'it'\''s'`) {
t.Errorf("got %q", got)
}
})
t.Run("stdin arg omits the retry line", func(t *testing.T) {
if got := retryCommandWithYes([]string{"lark-cli", "sheets", "+batch-update", "--operations", "-"}); got != "" {
t.Errorf("stdin invocation must not render a retry line, got %q", got)
}
})
t.Run("bundled stdin flag omits the retry line", func(t *testing.T) {
// --flag=- reads stdin the same as --flag -; both must suppress the line.
if got := retryCommandWithYes([]string{"lark-cli", "sheets", "+cells-set", "--cells=-"}); got != "" {
t.Errorf("--flag=- stdin invocation must not render a retry line, got %q", got)
}
})
t.Run("oversized command omits the retry line", func(t *testing.T) {
if got := retryCommandWithYes([]string{"lark-cli", "x", "--operations", strings.Repeat("a", 400)}); got != "" {
t.Errorf("oversized invocation must not render a retry line, got %q", got)
}
})
}

View File

@@ -7,9 +7,12 @@ import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
)
// ResolveInput resolves special input conventions for a raw flag value:
@@ -77,7 +80,25 @@ func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, er
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
// An absolute path under the system temp dir is read directly instead:
// agents stage generated payloads (@/tmp/ops.json) there as a matter of
// course, and the strict relative-to-cwd policy — load-bearing for uploads
// and drive sync — only cost @file callers a python/stdin detour.
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
resolved, terr := validate.SafeTempAbsInputPath(path)
if terr == nil {
data, err := os.ReadFile(resolved) //nolint:forbidigo // resolved is confined to the system temp dir by SafeTempAbsInputPath
if err != nil {
return nil, wrapInputFileError(path, err)
}
return data, nil
}
if filepath.IsAbs(path) {
// Absolute but outside the temp dir: surface the prescriptive error
// (relative path / temp-dir path / stdin) instead of the generic
// relative-only message the strict validator below would produce.
return nil, fmt.Errorf("invalid file path %q: %w", path, terr)
}
if fileIO == nil {
return nil, fmt.Errorf("file input is not available in this context")
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// sparkCodeMeta holds stable Spark app-role business-code classifications.
// Command-specific recovery guidance belongs in the Apps shortcut layer; the
// numeric code remains the source-specific discriminator on the error envelope.
var sparkCodeMeta = map[int]CodeMeta{
3340001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // request parameters are invalid
3344027: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role user count exceeds the service limit
3344028: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role department count exceeds the service limit
3344029: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role chat count exceeds the service limit
3344030: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // app administrator required
3344031: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // app administrator or developer required
3344034: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role ID
3344035: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // role does not exist
3344036: {Category: errs.CategoryAPI, Subtype: errs.SubtypeAlreadyExists}, // role ID already exists
3344037: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // app role count exceeds the service limit
3344038: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role name
3344039: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role description
3344040: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // unsupported member type
3344041: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid member ID
}
func init() { mergeCodeMeta(sparkCodeMeta, "spark") }

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
func TestLookupCodeMetaSparkRoleCodes(t *testing.T) {
tests := []struct {
code int
category errs.Category
subtype errs.Subtype
}{
{3340001, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344027, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344028, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344029, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344030, errs.CategoryAuthorization, errs.SubtypePermissionDenied},
{3344031, errs.CategoryAuthorization, errs.SubtypePermissionDenied},
{3344034, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344035, errs.CategoryAPI, errs.SubtypeNotFound},
{3344036, errs.CategoryAPI, errs.SubtypeAlreadyExists},
{3344037, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344038, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344039, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344040, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344041, errs.CategoryAPI, errs.SubtypeInvalidParameters},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%d", tt.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(tt.code)
if !ok {
t.Fatalf("code %d is not registered", tt.code)
}
if meta.Category != tt.category || meta.Subtype != tt.subtype || meta.Retryable {
t.Fatalf("code %d metadata = %+v, want category=%s subtype=%s retryable=false", tt.code, meta, tt.category, tt.subtype)
}
err := BuildAPIError(map[string]any{
"code": tt.code,
"msg": "spark role error",
"log_id": "log-spark-role",
}, ClassifyContext{Identity: "user"})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("BuildAPIError(%d) = %#v, want typed problem", tt.code, err)
}
if problem.Category != tt.category || problem.Subtype != tt.subtype || problem.Code != tt.code || problem.LogID != "log-spark-role" || problem.Retryable {
t.Fatalf("BuildAPIError(%d) problem = %+v", tt.code, problem)
}
})
}
}

View File

@@ -337,7 +337,7 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
case name == "open_id" || hasPlaceholderToken(tokens, "user", "owner", "participant", "approver", "speaker"):
return "ou_test123", true
case hasPlaceholderToken(tokens, "department", "dept"):
return "od_test123", true
return "od-test123", true
case hasPlaceholderToken(tokens, "message"):
return "om_test123", true
case name == "file_key":

View File

@@ -316,6 +316,13 @@ func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) {
}
}
func TestFakeValueFromPlaceholderNameUsesOpenDepartmentPrefix(t *testing.T) {
got, ok := fakeValueFromPlaceholderName("open_department_id")
if !ok || got != "od-test123" {
t.Fatalf("open_department_id placeholder = %q, %v; want od-test123, true", got, ok)
}
}
func TestRunDryRunsMaterializesNumericPlaceholderFlagValues(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/vc/v1/bots/events","params":{"meeting_id":"400000000001","page_size":50}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{

View File

@@ -17,6 +17,12 @@ func SafeInputPath(path string) (string, error) {
return localfileio.SafeInputPath(path)
}
// SafeTempAbsInputPath accepts an absolute read path only when it resolves
// under the system temp dir. Delegates to localfileio.SafeTempAbsInputPath.
func SafeTempAbsInputPath(path string) (string, error) {
return localfileio.SafeTempAbsInputPath(path)
}
// SafeEnvDirPath validates an environment-provided application directory path.
// Delegates to localfileio.SafeEnvDirPath.
func SafeEnvDirPath(path, envName string) (string, error) {

View File

@@ -5,6 +5,7 @@ package localfileio
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -18,10 +19,35 @@ func SafeOutputPath(path string) (string, error) {
}
// SafeInputPath validates an upload/read source path for --file flags.
// Deliberately strict (relative-to-cwd only): several callers — drive sync,
// upload flags, the CI quality gates — treat "absolute paths rejected" as a
// load-bearing invariant. The one deliberate exception is the @file payload
// expansion, which layers SafeTempAbsInputPath on top (see cmdutil).
func SafeInputPath(path string) (string, error) {
return safePath(path, "--file")
}
// SafeTempAbsInputPath accepts an absolute READ path only when it resolves
// under the canonical system temp dir. Agents stage generated payloads
// (batch operations JSON, CSV) in /tmp as a matter of course, and rejecting
// @/tmp/ops.json only pushed them through an extra python/stdin round trip
// (recurring friction cluster in eval traces). Reads under os.TempDir()
// carry no write risk and no project-escape risk. Errors for anything else
// (relative paths included) — callers fall back to SafeInputPath semantics.
func SafeTempAbsInputPath(path string) (string, error) {
if err := charcheck.RejectControlChars(path, "--file"); err != nil {
return "", err
}
if !isAbsolutePath(path) {
return "", fmt.Errorf("--file %q is not an absolute path", path)
}
resolved, ok := absPathUnderTempDir(path)
if !ok {
return "", fmt.Errorf("--file must be a relative path within the current directory, or an absolute path under the system temp dir (%s), got %q (hint: use ./filename or a %s path; flags that support stdin can read any file via '-' instead)", os.TempDir(), path, os.TempDir())
}
return resolved, nil
}
// SafeLocalFlagPath validates a flag value as a local file path.
// Empty values and http/https URLs are returned unchanged without validation.
func SafeLocalFlagPath(flagName, value string) (string, error) {
@@ -96,6 +122,26 @@ func safePath(raw, flagName string) (string, error) {
return resolved, nil
}
// absPathUnderTempDir accepts an absolute path only when, after cleaning and
// resolving symlinks (through the nearest existing ancestor for
// not-yet-created files), it still lives under the canonical system temp dir.
// A symlink inside the temp dir pointing outside it resolves outside and is
// rejected.
func absPathUnderTempDir(raw string) (string, bool) {
canonicalTmp, err := filepath.EvalSymlinks(os.TempDir())
if err != nil {
return "", false
}
resolved, err := resolveNearestAncestor(filepath.Clean(raw))
if err != nil {
return "", false
}
if !isUnderDir(resolved, canonicalTmp) || resolved == canonicalTmp {
return "", false
}
return resolved, true
}
func resolveNearestAncestor(path string) (string, error) {
var tail []string
cur := path

View File

@@ -175,7 +175,7 @@ func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) {
}
}
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
func TestSafeUploadPath_RejectsTempFileAbsolutePath(t *testing.T) {
// GIVEN: a real temp file (absolute path under os.TempDir())
f, err := os.CreateTemp("", "upload-test-*.bin")
if err != nil {
@@ -185,15 +185,67 @@ func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
f.Close()
t.Cleanup(func() { os.Remove(tmpPath) })
// WHEN: SafeUploadPath validates the absolute temp path
// WHEN: SafeInputPath validates the absolute temp path
_, err = SafeInputPath(tmpPath)
// THEN: absolute paths are rejected even in temp dir
// THEN: the strict validator rejects it — uploads / drive sync rely on
// relative-only; temp-dir reads go through SafeTempAbsInputPath instead
if err == nil {
t.Fatal("expected error for absolute temp path, got nil")
}
}
func TestSafeTempAbsInputPath(t *testing.T) {
t.Run("accepts a file under the temp dir", func(t *testing.T) {
f, err := os.CreateTemp("", "payload-*.json")
if err != nil {
t.Fatalf("CreateTemp: %v", err)
}
tmpPath := f.Name()
f.Close()
t.Cleanup(func() { os.Remove(tmpPath) })
resolved, err := SafeTempAbsInputPath(tmpPath)
if err != nil {
t.Fatalf("expected temp path accepted, got %v", err)
}
canonical, err := filepath.EvalSymlinks(tmpPath)
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}
if resolved != canonical {
t.Fatalf("resolved = %q, want %q", resolved, canonical)
}
})
t.Run("rejects relative paths", func(t *testing.T) {
if _, err := SafeTempAbsInputPath("./ops.json"); err == nil {
t.Fatal("expected error for relative path, got nil")
}
})
t.Run("rejects absolute paths outside the temp dir", func(t *testing.T) {
if _, err := SafeTempAbsInputPath("/etc/passwd"); err == nil {
t.Fatal("expected error for non-temp absolute path, got nil")
}
})
t.Run("rejects a temp-dir symlink escaping outside", func(t *testing.T) {
dir, err := os.MkdirTemp("", "escape-*")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
link := filepath.Join(dir, "escape.json")
if err := os.Symlink("/etc/passwd", link); err != nil {
t.Skipf("symlink not supported: %v", err)
}
if _, err := SafeTempAbsInputPath(link); err == nil {
t.Fatal("expected error for symlink escaping the temp dir, got nil")
}
})
}
func TestSafeUploadPath_RejectsNonTempAbsolutePath(t *testing.T) {
for _, tt := range []struct {
name string

View File

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

View File

@@ -18,6 +18,11 @@ workflow_permissions="$(awk '
in_permissions && /^[^[:space:]]/ { exit }
in_permissions { print }
' "$workflow")"
workflow_concurrency="$(awk '
/^concurrency:/ { in_concurrency = 1; print; next }
in_concurrency && /^[^[:space:]]/ { exit }
in_concurrency { print }
' "$workflow")"
fast_gate_section="$(job_section fast-gate)"
unit_test_section="$(job_section unit-test)"
lint_section="$(awk '
@@ -46,6 +51,27 @@ results_section="$(awk '
in_job { print }
' "$workflow")"
fork_safe_guard="github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork"
live_job_condition="always() && ($fork_safe_guard) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != ''"
if ! grep -Fq "run-name: \${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}" "$workflow"; then
echo "CI should expose a stable PR generation while preserving default push and manual run titles" >&2
exit 1
fi
if ! grep -Fq "RUN_GENERATION: \${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}" <<<"$section"; then
echo "the supersession generation should match the PR-only run name" >&2
exit 1
fi
if ! grep -Fq 'group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}' <<<"$workflow_concurrency"; then
echo "CI should deduplicate runs for the same pull request without grouping push or manual runs" >&2
exit 1
fi
if ! grep -Fq "cancel-in-progress: \${{ github.event_name == 'pull_request' }}" <<<"$workflow_concurrency"; then
echo "CI should cancel superseded pull request runs but preserve push and manual runs" >&2
exit 1
fi
for denied_permission in "checks: write" "pull-requests: write" "issues: write"; do
if grep -Eq "^[[:space:]]*${denied_permission}$" <<<"$workflow_permissions"; then
@@ -210,8 +236,84 @@ if ! grep -Fq "deterministic-gate" <<<"$results_section"; then
exit 1
fi
if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
echo "e2e-live should run on push and same-repository pull_request, but skip fork pull_request"
if ! grep -Fq "if: \${{ $live_job_condition }}" <<<"$section"; then
echo "e2e-live should preserve active cleanup while requiring a successful non-skip dry run and excluding fork pull requests"
exit 1
fi
if ! grep -Fq "needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]" <<<"$section"; then
echo "e2e-live should wait outside the exclusive queue until e2e-dry-run finishes"
exit 1
fi
if ! grep -Fq "timeout-minutes: 20" <<<"$dry_run_section"; then
echo "e2e-dry-run should bound the planning gate before live E2E" >&2
exit 1
fi
if ! grep -Fq "timeout-minutes: 30" <<<"$section"; then
echo "e2e-live should release the repository-wide slot after 30 minutes" >&2
exit 1
fi
if ! grep -Fq "group: lark-cli-e2e-live" <<<"$section"; then
echo "e2e-live should use one repository-wide execution slot" >&2
exit 1
fi
if ! grep -Fq "cancel-in-progress: false" <<<"$section"; then
echo "e2e-live should queue waiting runs instead of cancelling an active live test" >&2
exit 1
fi
if ! grep -Fq "queue: max" <<<"$section"; then
echo "e2e-live should preserve queued runs instead of replacing an existing pending run" >&2
exit 1
fi
if ! grep -Fq "actions: read" <<<"$section"; then
echo "e2e-live should use read-only Actions access for the supersession check" >&2
exit 1
fi
live_test_step="$(awk '
/^ - name: Run CLI E2E tests/ { in_step = 1 }
in_step { print }
in_step && /^ - name: Publish CLI E2E test report/ { exit }
' <<<"$section")"
if ! grep -Fq "if: \${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}" <<<"$live_test_step"; then
echo "the active live test step should survive ordinary workflow supersession only after setup succeeds" >&2
exit 1
fi
for required in \
'gh api "repos/$REPOSITORY/actions/runs/$RUN_ID"' \
'gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs"' \
'-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100' \
'.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number' \
'::error::Superseded before live E2E started' \
'exit 1'; do
if ! grep -Fq -- "$required" <<<"$live_test_step"; then
echo "the live startup check should fail closed before a superseded run starts live E2E: missing $required" >&2
exit 1
fi
done
if ! awk '
/if \[ -n "\$newer_runs" \]; then/ { superseded_state = 1; next }
superseded_state == 1 && /::error::Superseded before live E2E started/ { superseded_state = 2; next }
superseded_state == 2 && /^[[:space:]]+exit 1[[:space:]]*$/ { superseded_state = 3; next }
superseded_state > 0 && /^[[:space:]]+fi[[:space:]]*$/ {
if (superseded_state != 3) exit 2
superseded_closed = 1
superseded_state = 0
next
}
/go run gotest.tools\/gotestsum@/ { test_started = 1; if (!superseded_closed) exit 3 }
END { exit superseded_closed && test_started ? 0 : 1 }
' <<<"$live_test_step"; then
echo "a superseded live run must stop before gotestsum starts" >&2
exit 1
fi
@@ -222,6 +324,39 @@ if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
exit 1
fi
for output in \
'mode: ${{ steps.e2e_domains.outputs.mode }}' \
'reason: ${{ steps.e2e_domains.outputs.reason }}' \
'live_packages: ${{ steps.e2e_domains.outputs.live_packages }}'; do
if ! grep -Fq "$output" <<<"$dry_run_section"; then
echo "e2e-dry-run should publish $output for the live job" >&2
exit 1
fi
done
for validation_contract in \
'case "$E2E_MODE" in' \
'skip)' \
'[ -z "$E2E_LIVE_PACKAGES" ]' \
'full|subset)' \
'[ -n "$E2E_LIVE_PACKAGES" ]' \
'Invalid CLI E2E mode' \
'exit 1'; do
if ! grep -Fq "$validation_contract" <<<"$dry_run_section"; then
echo "e2e-dry-run should fail invalid domain output before live can be skipped: missing $validation_contract" >&2
exit 1
fi
done
if ! awk '
/- name: Validate CLI E2E domain outputs/ { validated = 1 }
/- name: Build lark-cli/ { exit validated ? 0 : 1 }
END { if (!validated) exit 1 }
' <<<"$dry_run_section"; then
echo "e2e-dry-run should validate domain outputs before building" >&2
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
exit 1
@@ -244,21 +379,21 @@ if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
! grep -Fq "id: e2e_domains" <<<"$section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
if grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
echo "e2e-live should reuse e2e-dry-run outputs instead of resolving domains again"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
echo "e2e-live should use resolved live_packages instead of always running the full suite"
if ! grep -Fq "E2E_LIVE_PACKAGES: \${{ needs.e2e-dry-run.outputs.live_packages }}" <<<"$section"; then
echo "e2e-live should reuse live_packages resolved by e2e-dry-run"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
if ! grep -Fq "E2E_MODE: \${{ needs.e2e-dry-run.outputs.mode }}" <<<"$section" ||
! grep -Fq "E2E_REASON: \${{ needs.e2e-dry-run.outputs.reason }}" <<<"$section" ||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
echo "e2e-live should pass dynamic domain output through env before shell use"
echo "e2e-live should consume the exact mode and reason produced by e2e-dry-run"
exit 1
fi
@@ -272,16 +407,23 @@ if ! awk '
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should skip building lark-cli when domain mode is skip"
if grep -Fq "steps.e2e_domains.outputs" <<<"$section"; then
echo "e2e-live should not retain step-local domain outputs after adopting the dry-run job gate"
exit 1
fi
for step_name in "Build lark-cli" "Prepare shared live E2E tenant token"; do
live_setup_step="$(awk -v name="$step_name" '
$0 == " - name: " name { in_step = 1 }
in_step { print }
in_step && /^ - name:/ && $0 != " - name: " name { exit }
' <<<"$section")"
if grep -Eq '^ if:' <<<"$live_setup_step"; then
echo "e2e-live $step_name should run unconditionally after the non-skip job gate" >&2
exit 1
fi
done
if ! grep -Fq "permissions:" <<<"$section" ||
! grep -Fq "contents: read" <<<"$section" ||
! grep -Fq "checks: write" <<<"$section"; then
@@ -299,18 +441,88 @@ if grep -Fq "live_e2e_credentials" <<<"$section" || grep -Fq "configured=false"
exit 1
fi
if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET" <<<"$section"; then
echo "e2e-live should make missing bot credentials a visible configuration failure on eligible runs"
if ! grep -Fq "node scripts/fetch_e2e_tat.js" <<<"$section"; then
echo "e2e-live should fetch the tenant token via the dedicated script"
exit 1
fi
if grep -Fq "config init" <<<"$section"; then
echo "e2e-live should use env credentials instead of config init"
exit 1
fi
if ! grep -Fq "TEST_BOT1_APP_ID: \${{ secrets.TEST_BOT1_APP_ID }}" <<<"$section"; then
echo "e2e-live should keep the bot app id under a test-only job env name"
exit 1
fi
if awk '
/^ e2e-live:/ { in_job = 1; next }
in_job && /^ [A-Za-z0-9_-]+:/ { in_job = 0 }
in_job && /^ env:/ { in_env = 1; next }
in_env && /^ steps:/ { in_env = 0 }
in_env && /LARKSUITE_CLI_APP_ID:/ { found_standard_app_id = 1 }
END { exit found_standard_app_id ? 0 : 1 }
' "$workflow"; then
echo "e2e-live should not activate the env credential provider at job scope"
exit 1
fi
if ! grep -Fq "LARKSUITE_CLI_BRAND: feishu" <<<"$section"; then
echo "e2e-live should pin the env credential brand to feishu"
exit 1
fi
if awk '
/^ e2e-live:/ { in_job = 1; next }
in_job && /^ [A-Za-z0-9_-]+:/ { in_job = 0 }
in_job && /^ env:/ { in_env = 1; next }
in_env && /^ steps:/ { in_env = 0 }
in_env && /(SECRET|ACCESS_TOKEN):/ { found_sensitive = 1 }
END { exit found_sensitive ? 0 : 1 }
' "$workflow"; then
echo "e2e-live should not expose live E2E credentials through job-level env"
exit 1
fi
if ! awk '
/^ - name: Configure bot credentials/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
END { exit found ? 0 : 1 }
/^ - name: Prepare shared live E2E tenant token/ { in_step = 1 }
in_step && /id: live_e2e_tat/ { has_id = 1 }
in_step && /^ if:/ { has_if = 1 }
in_step && /LARKSUITE_CLI_APP_ID: \$\{\{ secrets\.TEST_BOT1_APP_ID \}\}/ { has_app_id = 1 }
in_step && /secrets\.TEST_BOT1_APP_SECRET/ { has_bot_credential = 1 }
in_step && /node scripts\/fetch_e2e_tat\.js/ { has_script = 1 }
in_step && /GITHUB_ENV/ { uses_github_env = 1 }
in_step && /^ - name:/ && !/Prepare shared live E2E tenant token/ { in_step = 0 }
END { exit has_id && !has_if && has_app_id && has_bot_credential && has_script && !uses_github_env ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should only configure bot credentials when domain mode is not skip"
echo "e2e-live should pass only a private tenant token file path through step output"
exit 1
fi
if ! awk '
/^ - name: Run CLI E2E tests/ { in_step = 1 }
in_step && /E2E_TENANT_AUTH_FILE: \$\{\{ steps\.live_e2e_tat\.outputs\.path \}\}/ { has_file = 1 }
in_step && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_user_credential = 1 }
in_step && /Missing shared live E2E tenant token file/ { checks_file = 1 }
in_step && /^ *export / && /TEST_TENANT_ACCESS_TOKEN/ && /E2E_TENANT_AUTH_FILE/ { exports_test_tat = 1 }
in_step && /^ *export / && /LARKSUITE_CLI_TENANT_ACCESS_TOKEN/ { exports_standard_tat = 1 }
in_step && /LARKSUITE_CLI_APP_ID="\$TEST_BOT1_APP_ID"/ { scopes_preflight_app_id = 1 }
in_step && /LARKSUITE_CLI_TENANT_ACCESS_TOKEN="\$TEST_TENANT_ACCESS_TOKEN"/ { scopes_preflight_tat = 1 }
in_step && /lark-cli whoami --as bot/ { has_preflight = 1 }
in_step && /Tenant credential preflight failed/ { checks_preflight = 1 }
in_step && /TEST_USER_ACCESS_TOKEN/ && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_user_env = 1 }
in_step && /LARKSUITE_CLI_USER_ACCESS_TOKEN/ && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_global_user_env = 1 }
in_step && /trap / { has_trap = 1 }
in_step && /^ - name:/ && !/Run CLI E2E tests/ { in_step = 0 }
END { exit has_file && has_user_credential && checks_file && exports_test_tat && !exports_standard_tat && scopes_preflight_app_id && scopes_preflight_tat && has_preflight && checks_preflight && has_user_env && !has_global_user_env && !has_trap ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should expose live E2E credentials only inside the test shell step"
exit 1
fi
if grep -Fq 'if [ "$E2E_MODE" = "skip" ]' <<<"$section"; then
echo "e2e-live should not retain an unreachable step-level skip branch"
exit 1
fi
@@ -319,8 +531,8 @@ if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
exit 1
fi
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
exit 1
fi
@@ -342,7 +554,7 @@ if grep -Fq '${{ secrets.CODECOV_TOKEN }}' <<<"$coverage_step" &&
fi
if grep -Fq '${{ secrets.' <<<"$section" &&
! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
! grep -Fq "$fork_safe_guard" <<<"$section"; then
echo "live E2E secrets should be available on push and same-repository pull_request, but not fork pull_request" >&2
exit 1
fi

164
scripts/fetch_e2e_tat.js Normal file
View File

@@ -0,0 +1,164 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Fetches a live E2E tenant access token (TAT) for the shared bot identity.
//
// Invoked from the e2e-live CI job. Exchanges the bot app id/secret for a
// tenant access token, writes the token to a private file under $RUNNER_TEMP,
// and emits the file path as a step output so the test step can read it once
// and then delete it.
//
// The secret arrives via environment variables; the OAuth parameter names are
// literal because this is a source code file (.js), so the quality gate's
// benign-code-credential exemption applies to the process.env references.
const fs = require("node:fs");
const http = require("node:http");
const https = require("node:https");
const path = require("node:path");
const { URL } = require("node:url");
const ENDPOINT = process.env.E2E_TAT_ENDPOINT || "https://accounts.feishu.cn/oauth/v3/token";
const MAX_ATTEMPTS = 4;
const RETRY_BASE_MS = parseInt(process.env.E2E_TAT_RETRY_BASE_MS || "1000", 10);
function requireEnv(name) {
const value = process.env[name];
if (!value) {
console.error(`::error::Missing required environment variable: ${name}`);
process.exit(1);
}
return value;
}
function postForm(url, body) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const transport = parsed.protocol === "http:" ? http : https;
const req = transport.request(
parsed,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(body),
},
timeout: 20000,
},
(resp) => {
const chunks = [];
let settled = false;
const rejectOnce = (error) => {
if (!settled) {
settled = true;
reject(error);
}
};
resp.on("data", (chunk) => chunks.push(chunk));
resp.on("aborted", () => rejectOnce(new Error("response aborted before completion")));
resp.on("error", rejectOnce);
resp.on("close", () => {
if (!resp.complete) {
rejectOnce(new Error("response closed before completion"));
}
});
resp.on("end", () => {
if (!resp.complete) {
rejectOnce(new Error("response ended before completion"));
return;
}
settled = true;
resolve({
status: resp.statusCode,
body: Buffer.concat(chunks).toString("utf8"),
headers: resp.headers,
});
});
},
);
req.on("timeout", () => {
req.destroy();
reject(new Error("request timed out"));
});
req.on("error", reject);
req.write(body);
req.end();
});
}
function encodeForm(params) {
return Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join("&");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchTenantToken() {
const appId = requireEnv("LARKSUITE_CLI_APP_ID");
const appSecret = requireEnv("TEST_BOT1_APP_SECRET");
const body = encodeForm({
grant_type: "client_credentials",
client_id: appId,
client_secret: appSecret,
});
let lastError = "";
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
const { status, body: respBody, headers } = await postForm(ENDPOINT, body);
let payload;
try {
payload = JSON.parse(respBody);
} catch {
const logID = headers["x-tt-logid"] || headers["x-request-id"] || "unavailable";
lastError = `HTTP ${status}, log_id=${logID}, non-JSON response`;
}
if (payload) {
const token = payload.access_token;
if (status === 200 && payload.code === 0 && token) {
return token;
}
lastError = `HTTP ${status}, code=${payload.code}, error=${payload.error}, msg=${payload.msg || payload.error_description}`;
}
} catch (err) {
lastError = err.message;
}
if (attempt < MAX_ATTEMPTS) {
await sleep(2 ** (attempt - 1) * RETRY_BASE_MS);
}
}
console.error(`::error::Failed to fetch tenant access token: ${lastError}`);
process.exit(1);
}
async function main() {
const token = await fetchTenantToken();
console.log(`::add-mask::${token}`);
const tatPath = path.join(process.env.RUNNER_TEMP, "e2e-live-tat");
fs.writeFileSync(tatPath, token, { encoding: "utf8", mode: 0o600 });
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `path=${tatPath}\n`);
}
console.log("Prepared shared live E2E tenant token");
}
if (require.main === module) {
main();
}
module.exports = {
encodeForm,
fetchTenantToken,
postForm,
requireEnv,
};

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const test = require("node:test");
const scriptPath = path.join(__dirname, "fetch_e2e_tat.js");
function startServer(handler) {
const server = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
handler(req, res, body);
});
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const port = server.address().port;
resolve({ server, port });
});
});
}
function abortResponse(res) {
res.writeHead(200, {
"Content-Type": "application/json",
"Content-Length": "100",
});
res.write('{"code":0');
setImmediate(() => res.destroy());
}
function runScript(envOverrides) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fetch-e2e-tat-"));
const githubOutput = path.join(tmpDir, "github-output");
const env = {
...process.env,
LARKSUITE_CLI_APP_ID: "test_app_id",
TEST_BOT1_APP_SECRET: "test-secret",
RUNNER_TEMP: tmpDir,
GITHUB_OUTPUT: githubOutput,
E2E_TAT_RETRY_BASE_MS: "10",
...envOverrides,
};
return new Promise((resolve) => {
const child = spawn(process.execPath, [scriptPath], {
cwd: path.join(__dirname, ".."),
env,
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (data) => {
stdout += data;
});
child.stderr.on("data", (data) => {
stderr += data;
});
child.on("close", (code) => {
const output = fs.existsSync(githubOutput)
? fs.readFileSync(githubOutput, "utf8")
: "";
resolve({ tmpDir, stdout, stderr, output, exitCode: code });
});
});
}
test("encodeForm encodes form parameters", () => {
const { encodeForm } = require(scriptPath);
const result = encodeForm({
grant_type: "client_credentials",
client_id: "abc&def",
client_secret: "test-secret",
note: "x=y",
});
const params = new URLSearchParams(result);
assert.equal(params.get("grant_type"), "client_credentials");
assert.equal(params.get("client_id"), "abc&def");
assert.equal(params.get("client_secret"), "test-secret");
assert.equal(params.get("note"), "x=y");
});
test("exits with error when app id is missing", async () => {
const result = await runScript({ LARKSUITE_CLI_APP_ID: "" });
assert.notEqual(result.exitCode, 0);
assert.match(result.stderr, /Missing required environment variable: LARKSUITE_CLI_APP_ID/);
});
test("exits with error when app secret is missing", async () => {
const result = await runScript({ TEST_BOT1_APP_SECRET: "" });
assert.notEqual(result.exitCode, 0);
assert.match(result.stderr, /Missing required environment variable: TEST_BOT1_APP_SECRET/);
});
test("fetches token and writes it to a private file", async () => {
const { server, port } = await startServer((req, res, body) => {
assert.equal(req.method, "POST");
const params = new URLSearchParams(body);
assert.equal(params.get("grant_type"), "client_credentials");
assert.equal(params.get("client_id"), "test_app_id");
assert.equal(params.get("client_secret"), "test-secret");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ code: 0, access_token: "test-token" }));
});
try {
const result = await runScript({
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
});
assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
assert.ok(result.stdout.includes("::add-mask::test-token"));
assert.ok(result.stdout.includes("Prepared shared live E2E tenant token"));
const tatPath = path.join(result.tmpDir, "e2e-live-tat");
assert.ok(fs.existsSync(tatPath), "token file should exist");
const stat = fs.statSync(tatPath);
assert.equal(stat.mode & 0o777, 0o600, "token file should be owner-only");
assert.equal(fs.readFileSync(tatPath, "utf8"), "test-token");
assert.ok(
result.output.includes(`path=${tatPath}`),
"should write path to GITHUB_OUTPUT",
);
} finally {
server.close();
}
});
test("retries an interrupted response and then succeeds", async () => {
let requestCount = 0;
const { server, port } = await startServer((req, res) => {
requestCount++;
if (requestCount === 1) {
abortResponse(res);
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ code: 0, access_token: "test-token" }));
});
try {
const result = await runScript({
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
});
assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
assert.equal(requestCount, 2);
} finally {
server.close();
}
});
test("fails after every interrupted response is retried", async () => {
let requestCount = 0;
const { server, port } = await startServer((req, res) => {
requestCount++;
abortResponse(res);
});
try {
const result = await runScript({
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
});
assert.notEqual(result.exitCode, 0);
assert.equal(requestCount, 4);
assert.match(result.stderr, /Failed to fetch tenant access token/);
} finally {
server.close();
}
});
test("exits with error after all retries fail", async () => {
let requestCount = 0;
const { server, port } = await startServer((req, res) => {
requestCount++;
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ code: 500, error: "server error" }));
});
try {
const result = await runScript({
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
});
assert.notEqual(result.exitCode, 0);
assert.equal(requestCount, 4);
assert.match(result.stderr, /Failed to fetch tenant access token/);
} finally {
server.close();
}
});

View File

@@ -20,7 +20,7 @@ func TestAppsAccessScopeGet_Specific(t *testing.T) {
"data": map[string]interface{}{
"scope": "Range",
"users": []interface{}{"ou_x", "ou_y"},
"departments": []interface{}{"od_z"},
"departments": []interface{}{"od-z"},
"chats": []interface{}{"oc_g"},
"apply_config": map[string]interface{}{
"enabled": true,
@@ -39,7 +39,7 @@ func TestAppsAccessScopeGet_Specific(t *testing.T) {
if !strings.Contains(got, `"scope": "Range"`) {
t.Fatalf("scope string not preserved (expect raw \"Range\"): %s", got)
}
if !strings.Contains(got, `"ou_x"`) || !strings.Contains(got, `"od_z"`) || !strings.Contains(got, `"oc_g"`) {
if !strings.Contains(got, `"ou_x"`) || !strings.Contains(got, `"od-z"`) || !strings.Contains(got, `"oc_g"`) {
t.Fatalf("users/departments/chats fields missing in envelope: %s", got)
}
if !strings.Contains(got, `"ou_appr"`) {

744
shortcuts/apps/apps_role.go Normal file
View File

@@ -0,0 +1,744 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"strconv"
"strings"
"text/tabwriter"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const maxRoleListScanPages = 1000
// AppsRoleList lists app roles.
var AppsRoleList = common.Shortcut{
Service: appsService,
Command: "+role-list",
Description: "List app roles",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +role-list --app-id <app_id>",
"Example: lark-cli apps +role-list --app-id <app_id> --name Admin --page-size 20",
"When only a role name is known, pass --name for exact matching; call +role-get only after resolving one unique role_id",
"With --name, the CLI scans server pages in batches of 100, then applies --page-size and --page-token to the exact local matches",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "name", Desc: "filter roles by exact name"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "integer offset returned by the previous role-list response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleAppID(rctx); err != nil {
return err
}
_, err := buildRoleListParams(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
// Validate already ran and called buildRoleListParams; error is impossible here.
params, _ := buildRoleListParams(rctx)
params = roleListRequestParams(params, 0)
return common.NewDryRunAPI().
GET(roleListURL(rctx)).
Desc("List app roles").
Params(params)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
params, err := buildRoleListParams(rctx)
if err != nil {
return err
}
data, err := executeRoleList(rctx, params)
if err != nil {
return withRoleErrorHint(err, roleOperationList)
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleListPretty(w, common.GetSlice(data, "items"))
})
return nil
},
}
// AppsRoleGet gets one app role.
var AppsRoleGet = common.Shortcut{
Service: appsService,
Command: "+role-get",
Description: "Get an app role",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +role-get --app-id <app_id> --role-id <role_id>",
"--role-id is not a human-readable role name; if only a name is known, run +role-list --name <exact_name> and use its unique returned role_id before calling +role-get",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
return validateRoleID(rctx)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET(roleItemURL(rctx)).
Desc("Get app role")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("GET", roleItemURL(rctx), nil, nil)
if err != nil {
return withRoleErrorHint(err, roleOperationGet)
}
role, err := parseRoleDetailResponseData(data, roleID(rctx))
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleGetPretty(w, role)
})
return nil
},
}
// AppsRoleCreate creates an app role.
var AppsRoleCreate = common.Shortcut{
Service: appsService,
Command: "+role-create",
Description: "Create an app role",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +role-create --app-id <app_id> --name Admin",
"Example: lark-cli apps +role-create --app-id <app_id> --name Admin --description 'Can manage orders'",
"Example: lark-cli apps +role-create --app-id <app_id> --name Admin --role-id role_admin",
"The create response returns data.role; run +role-get with data.role.role_id only when independent verification is required",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
// Keep --name in Validate so the CLI can return the command-specific
// non-invention hint instead of Cobra's generic required-flag error.
{Name: "name", Desc: "role name (required)"},
{Name: "description", Desc: "role description"},
{Name: "role-id", Desc: "optional caller-provided role ID ([A-Za-z0-9_-]{1,64})"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleAppID(rctx); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("name")) == "" {
return appsValidationParamError("--name", "--name is required").
WithHint("ask for the intended role name and pass it with --name; do not infer a name from --description")
}
if rctx.Changed("role-id") {
roleID := strings.TrimSpace(rctx.Str("role-id"))
if roleID == "" {
return appsValidationParamError("--role-id", "--role-id must not be empty when provided")
}
return validateOptionalRoleID(roleID)
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST(roleListURL(rctx)).
Desc("Create app role").
Body(buildRoleCreateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("POST", roleListURL(rctx), nil, buildRoleCreateBody(rctx))
if err != nil {
return withRoleErrorHint(err, roleOperationCreate)
}
expectedRoleID := ""
if rctx.Changed("role-id") {
expectedRoleID = roleID(rctx)
}
role, err := parseRoleWriteResponseData(data, expectedRoleID)
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleCreatePretty(w, role)
})
return nil
},
}
// AppsRoleUpdate updates an app role.
var AppsRoleUpdate = common.Shortcut{
Service: appsService,
Command: "+role-update",
Description: "Update an app role",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +role-update --app-id <app_id> --role-id <role_id> --name Operator",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
{Name: "name", Desc: "new role name"},
{Name: "description", Desc: "new role description"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleID(rctx); err != nil {
return err
}
if rctx.Changed("name") && strings.TrimSpace(rctx.Str("name")) == "" {
return appsValidationParamError("--name", "--name must not be empty when provided").
WithHint("omit --name if only updating --description")
}
if !rctx.Changed("name") && !rctx.Changed("description") {
reason := "provide at least one of --name or --description"
return appsValidationError("at least one of --name or --description is required").
WithParams(
appsInvalidParam("--name", reason),
appsInvalidParam("--description", reason),
).
WithHint("provide --name, --description, or both")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
PATCH(roleItemURL(rctx)).
Desc("Update app role").
Body(buildRoleUpdateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("PATCH", roleItemURL(rctx), nil, buildRoleUpdateBody(rctx))
if err != nil {
return withRoleErrorHint(err, roleOperationUpdate)
}
role, err := parseRoleWriteResponseData(data, roleID(rctx))
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleUpdatePretty(w, role)
})
return nil
},
}
// AppsRoleDelete deletes an app role.
var AppsRoleDelete = common.Shortcut{
Service: appsService,
Command: "+role-delete",
Description: "Delete an app role",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +role-delete --app-id <app_id> --role-id <role_id> --yes",
"A delete request alone is not explicit confirmation: first show the exact app, role, current member scope, and irreversible impact; use --yes only after the user confirms that impact",
"When independent verification is required, use +role-list --name <exact_name> and confirm the deleted role_id is absent; a failed +role-get alone does not prove deletion",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
return validateRoleID(rctx)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
DELETE(roleItemURL(rctx)).
Desc("Delete app role")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("DELETE", roleItemURL(rctx), nil, nil)
if err != nil {
return withRoleErrorHint(err, roleOperationDelete)
}
deletedRoleID := roleID(rctx)
out, err := normalizeRoleDeleteData(data, deletedRoleID)
if err != nil {
return err
}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderRoleDeletePretty(w, common.GetString(out, "role_id"))
})
return nil
},
}
func roleListURL(rctx *common.RuntimeContext) string {
appID := roleAppID(rctx)
return fmt.Sprintf(roleListPath, validate.EncodePathSegment(appID))
}
func roleItemURL(rctx *common.RuntimeContext) string {
appID := roleAppID(rctx)
roleID := roleID(rctx)
return fmt.Sprintf(roleItemPath, validate.EncodePathSegment(appID), validate.EncodePathSegment(roleID))
}
func buildRoleListParams(rctx *common.RuntimeContext) (map[string]interface{}, error) {
params, err := buildRolePageParams(rctx)
if err != nil {
return nil, err
}
name := strings.TrimSpace(rctx.Str("name"))
if rctx.Changed("name") && name == "" {
return nil, appsValidationParamError("--name", "--name must not be empty when provided").
WithHint("omit --name to list all roles, or provide the exact role name to resolve")
}
if name != "" {
params["name"] = name
}
return params, nil
}
// roleListRequestParams returns the query parameters for one actual backend
// request. Exact-name lookup always starts from server offset zero and scans in
// maximum-sized batches; the caller's limit/offset are applied to local matches.
func roleListRequestParams(params map[string]interface{}, page int) map[string]interface{} {
name, _ := params["name"].(string)
if name == "" {
return params
}
return map[string]interface{}{
"limit": maxRolePageSize,
"offset": page * maxRolePageSize,
"name": name,
}
}
func buildRoleCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"name": strings.TrimSpace(rctx.Str("name")),
}
if rctx.Changed("description") {
body["description"] = strings.TrimSpace(rctx.Str("description"))
}
if rctx.Changed("role-id") {
if roleID := strings.TrimSpace(rctx.Str("role-id")); roleID != "" {
body["role_id"] = roleID
}
}
return body
}
func buildRoleUpdateBody(rctx *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{}
if rctx.Changed("name") {
body["name"] = strings.TrimSpace(rctx.Str("name"))
}
if rctx.Changed("description") {
body["description"] = strings.TrimSpace(rctx.Str("description"))
}
return body
}
// executeRoleList compensates for Miaoda environments that accept the name
// query parameter but ignore it. A name lookup scans the complete server-side
// result set, applies exact matching locally, and then applies the CLI's
// offset/limit contract to the filtered result.
func executeRoleList(rctx *common.RuntimeContext, params map[string]interface{}) (map[string]interface{}, error) {
name, _ := params["name"].(string)
if name == "" {
data, err := rctx.CallAPITyped("GET", roleListURL(rctx), params, nil)
if err != nil {
return nil, err
}
return normalizeRoleListData(data, params)
}
requestedLimit := roleIntValue(params["limit"])
requestedOffset := roleIntValue(params["offset"])
allMatches := make([]interface{}, 0, requestedLimit)
var firstPage map[string]interface{}
seenRoleIDs := map[string]struct{}{}
seenPageSignatures := map[string]struct{}{}
expectedTotal := -1
scannedRoleCount := 0
for page := 0; ; page++ {
if page >= maxRoleListScanPages {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role list exceeded %d pages while filtering by name",
maxRoleListScanPages,
).WithHint("retry without --name and paginate using the returned page_token")
}
scanParams := roleListRequestParams(params, page)
data, err := rctx.CallAPITyped("GET", roleListURL(rctx), scanParams, nil)
if err != nil {
return nil, err
}
if firstPage == nil {
firstPage = data
}
items, hasMore, total, err := parseRoleListPage(data)
if err != nil {
return nil, err
}
if expectedTotal < 0 {
expectedTotal = total
} else if total != expectedTotal {
return nil, roleListProgressError("role list total changed across pages while filtering by name")
}
if scannedRoleCount+len(items) > expectedTotal {
return nil, roleListProgressError("role list returned more roles than its total while filtering by name")
}
scannedRoleCount += len(items)
if hasMore && scannedRoleCount >= expectedTotal {
return nil, roleListProgressError("role list reported more pages after reaching its total while filtering by name")
}
if !hasMore && scannedRoleCount != expectedTotal {
return nil, roleListProgressError("role list ended before returning its declared total while filtering by name")
}
signature, newRoleCount, err := roleListPageProgress(items, seenRoleIDs)
if err != nil {
return nil, err
}
if newRoleCount != len(items) {
return nil, roleListProgressError("role list repeated roles across pages while filtering by name")
}
if _, duplicate := seenPageSignatures[signature]; duplicate {
return nil, roleListProgressError("role list repeated a page while filtering by name")
}
seenPageSignatures[signature] = struct{}{}
if hasMore && (len(items) == 0 || newRoleCount == 0) {
return nil, roleListProgressError("role list reported more pages without returning new roles")
}
for _, item := range items {
role, ok := item.(map[string]interface{})
if ok && common.GetString(role, "name") == name {
allMatches = append(allMatches, item)
}
}
if !hasMore {
break
}
}
if firstPage == nil {
firstPage = map[string]interface{}{}
}
return normalizeFilteredRoleListData(firstPage, allMatches, requestedOffset, requestedLimit), nil
}
func normalizeFilteredRoleListData(data map[string]interface{}, matches []interface{}, offset, limit int) map[string]interface{} {
out := map[string]interface{}{}
for k, v := range data {
out[k] = v
}
start := offset
if start > len(matches) {
start = len(matches)
}
end := start + limit
if end > len(matches) {
end = len(matches)
}
hasMore := end < len(matches)
items := append([]interface{}(nil), matches[start:end]...)
if items == nil {
items = []interface{}{}
}
out["items"] = items
out["has_more"] = hasMore
out["page_token"] = roleNextPageToken(start, limit, hasMore)
out["total"] = len(matches)
return out
}
func normalizeRoleListData(data map[string]interface{}, params map[string]interface{}) (map[string]interface{}, error) {
items, hasMore, total, err := parseRoleListPage(data)
if err != nil {
return nil, err
}
out := map[string]interface{}{}
for k, v := range data {
out[k] = v
}
limit := roleIntValue(params["limit"])
offset := roleIntValue(params["offset"])
out["items"] = items
out["has_more"] = hasMore
out["page_token"] = roleNextPageToken(offset, limit, hasMore)
out["total"] = total
return out, nil
}
func parseRoleListPage(data map[string]interface{}) ([]interface{}, bool, int, error) {
rawItems, hasItems := data["items"]
items, ok := rawItems.([]interface{})
if !hasItems || !ok {
return nil, false, 0, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role list response field items must be an array",
).WithHint("retry the read; do not treat a missing or malformed role list as empty")
}
if err := validateRoleCollection(items, "role list response field items"); err != nil {
return nil, false, 0, err
}
rawHasMore, hasHasMore := data["has_more"]
hasMore, ok := rawHasMore.(bool)
if !hasHasMore || !ok {
return nil, false, 0, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role list response field has_more must be a boolean",
).WithHint("retry the read; pagination is incomplete without a valid has_more value")
}
total, ok := nonNegativeRoleInteger(data["total"])
if _, exists := data["total"]; !exists || !ok {
return nil, false, 0, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role list response field total must be a non-negative integer",
).WithHint("retry the read; do not infer a role count from a missing or malformed total value")
}
return items, hasMore, total, nil
}
func roleListPageProgress(items []interface{}, seenRoleIDs map[string]struct{}) (string, int, error) {
roleIDs := make([]string, 0, len(items))
newRoleCount := 0
for index, item := range items {
_, roleID, err := roleCollectionItem(item, "role list response field items", index)
if err != nil {
return "", 0, err
}
roleIDs = append(roleIDs, roleID)
if _, seen := seenRoleIDs[roleID]; !seen {
seenRoleIDs[roleID] = struct{}{}
newRoleCount++
}
}
return strings.Join(roleIDs, "\x00"), newRoleCount, nil
}
func nonNegativeRoleInteger(value interface{}) (int, bool) {
maxInt := uint64(^uint(0) >> 1)
toInt := func(value int64) (int, bool) {
if value < 0 || uint64(value) > maxInt {
return 0, false
}
return int(value), true
}
switch value := value.(type) {
case int:
if value < 0 {
return 0, false
}
return value, true
case int64:
return toInt(value)
case float64:
maxIntExclusive := math.Ldexp(1, strconv.IntSize-1)
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || math.Trunc(value) != value || value >= maxIntExclusive {
return 0, false
}
return int(value), true
case json.Number:
parsed, err := value.Int64()
if err != nil {
return 0, false
}
return toInt(parsed)
case string:
if value == "" || strings.IndexFunc(value, func(r rune) bool {
return r < '0' || r > '9'
}) >= 0 {
return 0, false
}
parsed, err := strconv.ParseUint(value, 10, strconv.IntSize)
if err != nil || parsed > maxInt {
return 0, false
}
return int(parsed), true
default:
return 0, false
}
}
func roleListProgressError(message string) error {
return errs.NewInternalError(errs.SubtypeInvalidResponse, message).
WithHint("retry without --name and paginate manually; do not continue an incomplete exact-name scan")
}
func normalizeRoleDeleteData(data map[string]interface{}, requestedRoleID string) (map[string]interface{}, error) {
if data == nil {
return nil, invalidRoleDeleteResponse("role delete response data must be an object")
}
if len(data) == 0 {
return map[string]interface{}{
"role_id": requestedRoleID,
"deleted": true,
}, nil
}
out := map[string]interface{}{}
for k, v := range data {
out[k] = v
}
rawRoleID, ok := out["role_id"]
if !ok {
return nil, invalidRoleDeleteResponse("role delete response is missing role_id")
}
actualRoleID, stringOK := rawRoleID.(string)
if !stringOK || actualRoleID != requestedRoleID {
return nil, invalidRoleDeleteResponse(
"role delete response role_id does not match requested role_id %q",
requestedRoleID,
)
}
rawDeleted, ok := out["deleted"]
if !ok {
return nil, invalidRoleDeleteResponse("role delete response is missing deleted")
}
deleted, boolOK := rawDeleted.(bool)
if !boolOK || !deleted {
return nil, invalidRoleDeleteResponse("role delete response did not acknowledge deletion")
}
return out, nil
}
type roleResponseData struct {
RoleID string
Name string
Description string
}
func parseRoleDetailResponseData(data map[string]interface{}, expectedRoleID string) (roleResponseData, error) {
return parseRoleResponseData(data, expectedRoleID, true)
}
func parseRoleWriteResponseData(data map[string]interface{}, expectedRoleID string) (roleResponseData, error) {
return parseRoleResponseData(data, expectedRoleID, false)
}
func parseRoleResponseData(data map[string]interface{}, expectedRoleID string, requireName bool) (roleResponseData, error) {
if data == nil {
return roleResponseData{}, invalidRoleResponse("role response data must be an object")
}
rawRole, exists := data["role"]
role, ok := rawRole.(map[string]interface{})
if !exists || !ok || role == nil {
return roleResponseData{}, invalidRoleResponse("role response field role must be an object")
}
rawRoleID, exists := role["role_id"]
roleID, ok := rawRoleID.(string)
roleID = strings.TrimSpace(roleID)
if !exists || !ok || roleID == "" {
return roleResponseData{}, invalidRoleResponse("role response field role.role_id must be a non-empty string")
}
if expectedRoleID != "" && roleID != expectedRoleID {
return roleResponseData{}, invalidRoleResponse(
"role response role_id %q does not match requested role_id %q",
roleID,
expectedRoleID,
)
}
rawName, nameExists := role["name"]
name, nameOK := rawName.(string)
name = strings.TrimSpace(name)
if requireName && !nameExists {
return roleResponseData{}, invalidRoleResponse("role response field role.name must be a non-empty string")
}
if nameExists && (!nameOK || name == "") {
return roleResponseData{}, invalidRoleResponse("role response field role.name must be a non-empty string")
}
rawDescription, descriptionExists := role["description"]
description, descriptionOK := rawDescription.(string)
if descriptionExists && !descriptionOK {
return roleResponseData{}, invalidRoleResponse("role response field role.description must be a string")
}
return roleResponseData{RoleID: roleID, Name: name, Description: description}, nil
}
func invalidRoleResponse(message string, args ...interface{}) error {
return errs.NewInternalError(errs.SubtypeInvalidResponse, message, args...).
WithHint("retry the role read; do not treat a missing or malformed role as a successful result")
}
func invalidRoleDeleteResponse(message string, args ...interface{}) error {
return errs.NewInternalError(errs.SubtypeInvalidResponse, message, args...).
WithHint("do not claim deletion; verify the target role with +role-list --name <exact_name>")
}
func roleIntValue(value interface{}) int {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case json.Number:
i, err := strconv.Atoi(v.String())
if err == nil {
return i
}
case string:
i, err := strconv.Atoi(strings.TrimSpace(v))
if err == nil {
return i
}
}
return 0
}
func renderRoleCreatePretty(w io.Writer, role roleResponseData) {
fmt.Fprintf(w, "Created role %s\n", roleDisplayValue(role.RoleID))
}
func renderRoleGetPretty(w io.Writer, role roleResponseData) {
renderRoleDetailPretty(w, role)
}
func renderRoleUpdatePretty(w io.Writer, role roleResponseData) {
fmt.Fprintf(w, "Updated role %s\n", roleDisplayValue(role.RoleID))
}
func renderRoleDeletePretty(w io.Writer, roleID string) {
fmt.Fprintf(w, "Deleted role %s\n", roleDisplayValue(roleID))
}
func renderRoleDetailPretty(w io.Writer, role roleResponseData) {
fmt.Fprintf(w, "role_id: %s\n", roleDisplayValue(role.RoleID))
fmt.Fprintf(w, "name: %s\n", roleDisplayValue(role.Name))
fmt.Fprintf(w, "description: %s\n", roleDisplayValue(role.Description))
}
func renderRoleListPretty(w io.Writer, items []interface{}) {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ROLE ID\tNAME\tDESCRIPTION")
for _, item := range items {
role, ok := item.(map[string]interface{})
if !ok {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%s\n",
roleDisplayValue(firstNonEmpty(common.GetString(role, "role_id"), common.GetString(role, "id"))),
roleDisplayValue(common.GetString(role, "name")),
roleDisplayValue(common.GetString(role, "description")))
}
_ = tw.Flush()
}

View File

@@ -0,0 +1,490 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"fmt"
"regexp"
"strconv"
"strings"
"unicode"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
roleListPath = apiBasePath + "/apps/%s/roles"
roleItemPath = apiBasePath + "/apps/%s/roles/%s"
roleMemberListPath = apiBasePath + "/apps/%s/roles/%s/member_list"
roleMemberAddPath = apiBasePath + "/apps/%s/roles/%s/member_add"
roleMemberRemovePath = apiBasePath + "/apps/%s/roles/%s/member_remove"
roleMatchListPath = apiBasePath + "/apps/%s/user_role_list"
defaultRolePageSize = 20
maxRolePageSize = 100
maxRoleMembers = 100
roleErrInvalidParameters = 3340001
roleErrUserLimitExceeded = 3344027
roleErrDepartmentLimitExceeded = 3344028
roleErrChatLimitExceeded = 3344029
roleErrAdminRequired = 3344030
roleErrManagerRequired = 3344031
roleErrInvalidRoleID = 3344034
roleErrRoleNotFound = 3344035
roleErrRoleAlreadyExists = 3344036
roleErrRoleLimitExceeded = 3344037
roleErrInvalidRoleName = 3344038
roleErrInvalidRoleDescription = 3344039
roleErrUnsupportedMemberType = 3344040
roleErrInvalidMemberID = 3344041
)
var optionalRoleIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
const (
roleAppHint = "verify --app-id is a Miaoda app_id you can access; list apps with `lark-cli apps +list`"
roleItemHint = "verify --role-id belongs to the app; if you only know a role name, resolve it with `lark-cli apps +role-list --app-id <app_id> --name <exact_name>` and use the unique returned role_id"
roleCreateHint = "verify --app-id and role fields; omit --role-id unless you need a caller-provided role ID"
roleMemberHint = "verify --role-id and member IDs; use user open_id, open_department_id, or open_chat_id values"
roleMatchHint = "use --user-id with a user open_id; do not pass role_id or enumerate roles manually"
roleAppIDRequiredDesc = "Miaoda app ID (required; app_...; use apps +list to find it)"
roleIDRequiredDesc = "role ID (required; [A-Za-z0-9_-]{1,64}; use role-list to find it)"
roleUserIDRequiredDesc = "user open ID (required; ou_...; do not pass a role ID, name, or email)"
)
type roleErrorOperation uint8
const (
roleOperationList roleErrorOperation = iota
roleOperationGet
roleOperationCreate
roleOperationUpdate
roleOperationDelete
roleOperationMemberList
roleOperationMemberAdd
roleOperationMemberRemove
roleOperationMatchList
)
type roleMemberGroups struct {
Users []string `json:"users"`
Departments []string `json:"departments"`
Chats []string `json:"chats"`
}
type roleMemberKind struct {
memberType string
dataKey string
flagName string
prefix string
}
var roleMemberKinds = []roleMemberKind{
{memberType: "user", dataKey: "users", flagName: "--users", prefix: "ou_"},
{memberType: "department", dataKey: "departments", flagName: "--departments", prefix: "od-"},
{memberType: "chat", dataKey: "chats", flagName: "--chats", prefix: "oc_"},
}
func roleAppID(rctx *common.RuntimeContext) string {
return strings.TrimSpace(rctx.Str("app-id"))
}
func roleID(rctx *common.RuntimeContext) string {
return strings.TrimSpace(rctx.Str("role-id"))
}
func validateRoleAppID(rctx *common.RuntimeContext) error {
appID := roleAppID(rctx)
if appID == "" {
return appsValidationParamError("--app-id", "--app-id is required").
WithHint("list your apps with `lark-cli apps +list`")
}
if strings.HasPrefix(appID, "cli_") {
return appsValidationParamError("--app-id", "--app-id must be a Miaoda app_id, not a Lark app_id").
WithHint("pass the app_... value from `lark-cli apps +list`, not the cli_... credential app id")
}
if !strings.HasPrefix(appID, "app_") || len(appID) == len("app_") {
return appsValidationParamError("--app-id", "--app-id must be a Miaoda app_id starting with app_").
WithHint("list Miaoda apps with `lark-cli apps +list`, then pass the returned app_id")
}
// app-id must not contain forward slashes (apps are identified by app_xxx IDs).
for _, r := range appID {
if r == '/' || r == '\\' || unicode.IsSpace(r) || unicode.IsControl(r) {
return appsValidationParamError("--app-id", "--app-id must not contain slashes, whitespace, or control characters")
}
}
// Defense-in-depth: block path traversal and URL metacharacters.
if err := validateRolePathSegmentSafe(appID, "--app-id"); err != nil {
return err
}
return nil
}
func validateRoleID(rctx *common.RuntimeContext) error {
if err := validateRoleAppID(rctx); err != nil {
return err
}
roleID := roleID(rctx)
if roleID == "" {
return appsValidationParamError("--role-id", "--role-id is required").
WithHint("list roles with `lark-cli apps +role-list --app-id <app_id>`")
}
return validateExistingRoleIDValue(roleID)
}
// validateRolePathSegmentSafe rejects path-traversal segments ("..") and URL
// metacharacters (? # %) in values interpolated into a URL path, providing
// defense-in-depth alongside validate.EncodePathSegment.
func validateRolePathSegmentSafe(value, flagName string) error {
for _, seg := range strings.Split(value, "/") {
if seg == ".." {
return appsValidationParamError(flagName, "%s must not contain '..' path traversal", flagName).
WithHint("provide a valid %s without path traversal", flagName)
}
}
if strings.ContainsAny(value, "?#%") {
return appsValidationParamError(flagName, "%s contains invalid URL characters (?, #, %%)", flagName).
WithHint("provide a valid %s without URL metacharacters", flagName)
}
return nil
}
func validateOptionalRoleID(roleID string) error {
roleID = strings.TrimSpace(roleID)
if roleID == "" {
return nil
}
return validateCreateRoleIDValue(roleID)
}
func validateCreateRoleIDValue(roleID string) error {
if !optionalRoleIDPattern.MatchString(roleID) {
return appsValidationParamError("--role-id", "--role-id must match [A-Za-z0-9_-]{1,64}").
WithHint("omit --role-id to let the server generate one")
}
return nil
}
func validateExistingRoleIDValue(roleID string) error {
if !optionalRoleIDPattern.MatchString(roleID) {
return appsValidationParamError("--role-id", "--role-id must match [A-Za-z0-9_-]{1,64}").
WithHint("resolve the role with `lark-cli apps +role-list --app-id <app_id> --name <exact_name>` and pass its role_id")
}
return nil
}
func buildRolePageParams(rctx *common.RuntimeContext) (map[string]interface{}, error) {
limit := defaultRolePageSize
if rctx.Changed("page-size") {
limit = rctx.Int("page-size")
}
if limit < 1 || limit > maxRolePageSize {
return nil, appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxRolePageSize).
WithHint("use --page-size between 1 and 100")
}
offset := 0
pageToken := strings.TrimSpace(rctx.Str("page-token"))
if pageToken != "" {
parsedOffset, err := strconv.Atoi(pageToken)
if err != nil || parsedOffset < 0 {
return nil, appsValidationParamError("--page-token", "--page-token must be a non-negative integer offset").
WithHint("reuse page_token from the previous +role-list response")
}
offset = parsedOffset
}
return map[string]interface{}{
"limit": limit,
"offset": offset,
}, nil
}
func roleNextPageToken(offset, limit int, hasMore bool) string {
if !hasMore {
return ""
}
return strconv.Itoa(offset + limit)
}
func splitRoleMemberCSV(s, flagName string) ([]string, error) {
parts := strings.Split(s, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
continue
}
// Reject values containing whitespace, control characters, or URL metacharacters
// (member IDs are open_id/open_department_id/open_chat_id which are safe tokens).
if err := validateMemberID(value, flagName); err != nil {
return nil, err
}
values = append(values, value)
}
return values, nil
}
// validateMemberID rejects values containing characters that are invalid in
// open_id / open_department_id / open_chat_id tokens (whitespace, controls, URL metacharacters).
func validateMemberID(value, flagName string) error {
if err := validateMemberIDPrefix(value, flagName); err != nil {
return err
}
for _, r := range value {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return appsValidationParamError(flagName, "member IDs must not contain whitespace or control characters").
WithHint("pass comma-separated open_id/open_department_id/open_chat_id values without spaces")
}
if r == '?' || r == '#' || r == '%' || r == '/' || r == '\\' {
return appsValidationParamError(flagName, "member IDs must not contain URL metacharacters (?, #, %, /, \\)").
WithHint("pass comma-separated open_id/open_department_id/open_chat_id values without URL characters")
}
}
return nil
}
func validateMemberIDPrefix(value, flagName string) error {
kind, ok := roleMemberKindForFlag(flagName)
if !ok {
return nil
}
if !strings.HasPrefix(value, kind.prefix) || len(value) == len(kind.prefix) {
return appsValidationParamError(flagName, "%s must use %s IDs", flagName, kind.prefix).
WithHint("resolve names or emails to open IDs before calling role member commands")
}
return nil
}
func roleMemberKindForFlag(flagName string) (roleMemberKind, bool) {
if flagName == "--user-id" {
flagName = "--users"
}
for _, kind := range roleMemberKinds {
if kind.flagName == flagName {
return kind, true
}
}
return roleMemberKind{}, false
}
func roleMemberKindForType(memberType string) (roleMemberKind, bool) {
for _, kind := range roleMemberKinds {
if kind.memberType == memberType {
return kind, true
}
}
return roleMemberKind{}, false
}
func roleDisplayValue(value string) string {
value = validate.SanitizeForTerminal(value)
value = strings.NewReplacer("\n", " ", "\t", " ").Replace(value)
return strings.TrimSpace(value)
}
// withRoleErrorHint refines documented Spark role errors with command-specific
// recovery while preserving the typed error, numeric code, log_id, and any
// server-provided detail. Unknown codes retain the existing Apps fallback.
func withRoleErrorHint(err error, operation roleErrorOperation) error {
if err == nil {
return nil
}
problem, ok := errs.ProblemOf(err)
if !ok {
return err
}
hint := roleErrorHint(problem.Code, operation)
if hint == "" {
return withAppsHint(err, roleFallbackHint(operation))
}
existing := strings.TrimSpace(problem.Hint)
canonicalAPIHint := strings.TrimSpace(errclass.APIHint(problem.Subtype))
switch {
case existing == "", existing == canonicalAPIHint:
problem.Hint = hint
case !strings.Contains(existing, hint):
problem.Hint = existing + "; " + hint
}
return err
}
func roleFallbackHint(operation roleErrorOperation) string {
switch operation {
case roleOperationList:
return roleAppHint
case roleOperationCreate:
return roleCreateHint
case roleOperationMemberList, roleOperationMemberAdd, roleOperationMemberRemove:
return roleMemberHint
case roleOperationMatchList:
return roleMatchHint
default:
return roleItemHint
}
}
func roleErrorHint(code int, operation roleErrorOperation) string {
switch code {
case roleErrInvalidParameters:
return roleFallbackHint(operation)
case roleErrAdminRequired:
return "ask an app administrator to perform this operation or grant the calling user app-administrator access"
case roleErrManagerRequired:
return "ask an app administrator or app developer to perform this operation, or grant the calling user app-management access"
case roleErrInvalidRoleID:
if operation == roleOperationCreate {
return "omit --role-id to let the server generate one, or provide a role ID accepted by the role service"
}
case roleErrRoleNotFound:
if operation == roleOperationMatchList {
return "list the app's current roles and retry; role data used for this match may no longer be valid"
}
return roleItemHint
case roleErrRoleAlreadyExists:
if operation == roleOperationCreate {
return "choose a different --role-id or omit --role-id to let the server generate one"
}
case roleErrRoleLimitExceeded:
if operation == roleOperationCreate {
return "delete an unused app role before creating another role"
}
case roleErrInvalidRoleName:
if operation == roleOperationCreate || operation == roleOperationUpdate {
return "adjust --name to a non-empty value accepted by the role service"
}
case roleErrInvalidRoleDescription:
if operation == roleOperationCreate || operation == roleOperationUpdate {
return "adjust --description to a value accepted by the role service"
}
case roleErrUnsupportedMemberType:
if operation == roleOperationMemberList {
return "use --member-type user, department, or chat, or omit --member-type to list all member types"
}
case roleErrInvalidMemberID:
if operation == roleOperationMatchList {
return "resolve the target user to an open_id and retry with --user-id <open_id>"
}
if operation == roleOperationMemberAdd || operation == roleOperationMemberRemove {
return roleMemberHint
}
case roleErrUserLimitExceeded:
if operation == roleOperationMemberAdd {
return "reduce the users being added with --users, or remove unused user members before retrying"
}
case roleErrDepartmentLimitExceeded:
if operation == roleOperationMemberAdd {
return "reduce the departments being added with --departments, or remove unused department members before retrying"
}
case roleErrChatLimitExceeded:
if operation == roleOperationMemberAdd {
return "reduce the chats being added with --chats, or remove unused chat members before retrying"
}
}
return ""
}
func roleCollectionItem(item interface{}, collection string, index int) (map[string]interface{}, string, error) {
role, ok := item.(map[string]interface{})
if !ok {
return nil, "", invalidRoleCollectionResponse("%s item %d must be an object", collection, index)
}
rawRoleID, exists := role["role_id"]
roleID, stringOK := rawRoleID.(string)
roleID = strings.TrimSpace(roleID)
if !exists || !stringOK || roleID == "" {
return nil, "", invalidRoleCollectionResponse("%s item %d must contain a non-empty string role_id", collection, index)
}
rawName, exists := role["name"]
name, stringOK := rawName.(string)
if !exists || !stringOK || strings.TrimSpace(name) == "" {
return nil, "", invalidRoleCollectionResponse("%s item %d must contain a non-empty string name", collection, index)
}
return role, roleID, nil
}
func validateRoleCollection(items []interface{}, collection string) error {
for index, item := range items {
if _, _, err := roleCollectionItem(item, collection, index); err != nil {
return err
}
}
return nil
}
func invalidRoleCollectionResponse(format string, args ...interface{}) error {
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...).
WithHint("retry the read; do not treat missing or malformed role data as an empty or complete result")
}
func buildRoleMemberGroups(usersCSV, departmentsCSV, chatsCSV string) (roleMemberGroups, error) {
users, err := splitRoleMemberCSV(usersCSV, "--users")
if err != nil {
return roleMemberGroups{}, err
}
departments, err := splitRoleMemberCSV(departmentsCSV, "--departments")
if err != nil {
return roleMemberGroups{}, err
}
chats, err := splitRoleMemberCSV(chatsCSV, "--chats")
if err != nil {
return roleMemberGroups{}, err
}
groups := roleMemberGroups{
Users: users,
Departments: departments,
Chats: chats,
}
total := len(groups.Users) + len(groups.Departments) + len(groups.Chats)
if total == 0 {
reason := "provide at least one of --users, --departments, or --chats"
return groups, appsValidationError("at least one of --users, --departments, or --chats is required").
WithParams(
appsInvalidParam("--users", reason),
appsInvalidParam("--departments", reason),
appsInvalidParam("--chats", reason),
).
WithHint("resolve names to IDs first, then pass --users open_id, --departments open_department_id, or --chats open_chat_id")
}
if total > maxRoleMembers {
return groups, appsValidationError("role members cannot exceed %d", maxRoleMembers).
WithParams(roleMemberLimitParams(groups)...).
WithHint(fmt.Sprintf("reduce the atomic request to at most %d members; the CLI does not split member writes automatically", maxRoleMembers))
}
return groups, nil
}
func buildRoleMemberBody(groups roleMemberGroups) map[string]interface{} {
body := map[string]interface{}{}
if len(groups.Users) > 0 {
body["users"] = groups.Users
}
if len(groups.Departments) > 0 {
body["departments"] = groups.Departments
}
if len(groups.Chats) > 0 {
body["chats"] = groups.Chats
}
return body
}
func roleMemberLimitParams(groups roleMemberGroups) []errs.InvalidParam {
reason := fmt.Sprintf("combined role member count exceeds %d", maxRoleMembers)
params := make([]errs.InvalidParam, 0, len(roleMemberKinds))
if len(groups.Users) > 0 {
params = append(params, appsInvalidParam("--users", reason))
}
if len(groups.Departments) > 0 {
params = append(params, appsInvalidParam("--departments", reason))
}
if len(groups.Chats) > 0 {
params = append(params, appsInvalidParam("--chats", reason))
}
return params
}

View File

@@ -0,0 +1,447 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func newRoleRCtx(t *testing.T, flagDefs map[string]string, flags map[string]string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg)
cmd := &cobra.Command{Use: "test-role"}
cmd.SetContext(context.Background())
for name, typ := range flagDefs {
switch typ {
case "bool":
cmd.Flags().Bool(name, false, "")
case "int":
cmd.Flags().Int(name, 0, "")
case "string_array":
cmd.Flags().StringArray(name, nil, "")
default:
cmd.Flags().String(name, "", "")
}
}
for name, val := range flags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("set flag %q = %q: %v", name, val, err)
}
}
rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser)
return rctx, stdoutBuf, reg
}
func assertRoleValidationParam(t *testing.T, err error, param string) *errs.Problem {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %#v, want typed problem", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want validation", problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want invalid_argument", problem.Subtype)
}
var validation *errs.ValidationError
if !errors.As(err, &validation) {
t.Fatalf("err = %#v, want validation error", err)
}
if validation.Param != param {
t.Fatalf("param = %q, want %s", validation.Param, param)
}
return problem
}
func assertRoleValidationParams(t *testing.T, err error, params ...string) *errs.Problem {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %#v, want typed problem", err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %+v, want validation/invalid_argument", problem)
}
var validation *errs.ValidationError
if !errors.As(err, &validation) {
t.Fatalf("err = %#v, want validation error", err)
}
if validation.Param != "" {
t.Fatalf("param = %q, want omitted for multi-parameter constraint", validation.Param)
}
if len(validation.Params) != len(params) {
t.Fatalf("params = %#v, want %v", validation.Params, params)
}
for index, want := range params {
if validation.Params[index].Name != want || validation.Params[index].Reason == "" {
t.Fatalf("params[%d] = %#v, want name=%q with a reason", index, validation.Params[index], want)
}
}
return problem
}
func TestBuildRolePageParams_DefaultAndChanged(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"page-size": "int",
"page-token": "string",
}, map[string]string{})
params, err := buildRolePageParams(rctx)
if err != nil {
t.Fatalf("buildRolePageParams() = %v", err)
}
if params["limit"] != defaultRolePageSize || params["offset"] != 0 {
t.Fatalf("params = %#v, want limit=%d offset=0", params, defaultRolePageSize)
}
rctx, _, _ = newRoleRCtx(t, map[string]string{
"page-size": "int",
"page-token": "string",
}, map[string]string{"page-size": "20", "page-token": "40"})
params, err = buildRolePageParams(rctx)
if err != nil {
t.Fatalf("buildRolePageParams(changed) = %v", err)
}
if params["limit"] != 20 || params["offset"] != 40 {
t.Fatalf("params = %#v, want limit=20 offset=40", params)
}
}
func TestBuildRolePageParams_RejectsInvalidToken(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"page-size": "int",
"page-token": "string",
}, map[string]string{"page-token": "abc"})
_, err := buildRolePageParams(rctx)
assertRoleValidationParam(t, err, "--page-token")
}
func TestBuildRolePageParams_RejectsPageSizeOverMax(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"page-size": "int",
"page-token": "string",
}, map[string]string{"page-size": "101"})
_, err := buildRolePageParams(rctx)
assertRoleValidationParam(t, err, "--page-size")
}
func TestValidateOptionalRoleID(t *testing.T) {
for _, good := range []string{"", " role_001 ", "Role-ABC", "abc123", strings.Repeat("a", 64)} {
if err := validateOptionalRoleID(good); err != nil {
t.Fatalf("validateOptionalRoleID(%q) = %v", good, err)
}
}
for _, bad := range []string{"bad/role", "bad role", strings.Repeat("a", 65)} {
err := validateOptionalRoleID(bad)
problem := assertRoleValidationParam(t, err, "--role-id")
if !strings.Contains(problem.Hint, "omit --role-id") {
t.Fatalf("hint = %q, want create-specific omit guidance", problem.Hint)
}
}
}
func TestRoleFlagHelpersTrim(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
"role-id": "string",
}, map[string]string{"app-id": " app_1 ", "role-id": " role_1 "})
if got := roleAppID(rctx); got != "app_1" {
t.Fatalf("roleAppID() = %q, want app_1", got)
}
if got := roleID(rctx); got != "role_1" {
t.Fatalf("roleID() = %q, want role_1", got)
}
if err := validateRoleID(rctx); err != nil {
t.Fatalf("validateRoleID() = %v, want nil", err)
}
}
func TestValidateRoleAppIDRejectsEmpty(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
}, map[string]string{})
problem := assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
if problem.Message != "--app-id is required" {
t.Fatalf("message = %q, want --app-id is required", problem.Message)
}
if problem.Hint == "" {
t.Fatalf("hint is empty, want recovery guidance")
}
}
func TestValidateRoleAppIDRejectsPathSegmentUnsafeChars(t *testing.T) {
for _, appID := range []string{"app/bad", `app\bad`, "app bad", "app\u00a0bad", "app\nbad", "app\u0000bad"} {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
}, map[string]string{"app-id": appID})
assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
}
}
func TestValidateRoleAppIDRejectsLarkCredentialAppID(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
}, map[string]string{"app-id": "cli_app"})
assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
}
func TestValidateRoleAppIDRequiresMiaodaPrefix(t *testing.T) {
for _, appID := range []string{"app", "app_", "miaoda_123", "plain"} {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
}, map[string]string{"app-id": appID})
problem := assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
if !strings.Contains(problem.Message, "starting with app_") {
t.Fatalf("appID=%q message=%q, want app_ guidance", appID, problem.Message)
}
}
}
func TestValidateRoleIDRejectsInvalidRequiredRoleID(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
"role-id": "string",
}, map[string]string{"app-id": "app_x", "role-id": "bad/role"})
problem := assertRoleValidationParam(t, validateRoleID(rctx), "--role-id")
if strings.Contains(problem.Hint, "omit --role-id") || !strings.Contains(problem.Hint, "+role-list") {
t.Fatalf("hint = %q, want existing-role resolution guidance", problem.Hint)
}
}
func TestValidateRoleIDRejectsMissingRequiredRoleID(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
"role-id": "string",
}, map[string]string{"app-id": "app_x"})
problem := assertRoleValidationParam(t, validateRoleID(rctx), "--role-id")
if problem.Message != "--role-id is required" {
t.Fatalf("message = %q, want --role-id is required", problem.Message)
}
}
func TestBuildRoleMemberGroupsAndBody(t *testing.T) {
groups, err := buildRoleMemberGroups(" ou_a,ou_b ", " od-a ", " oc_a ")
if err != nil {
t.Fatalf("buildRoleMemberGroups() = %v", err)
}
if len(groups.Users) != 2 || len(groups.Departments) != 1 || len(groups.Chats) != 1 {
t.Fatalf("groups = %#v", groups)
}
body := buildRoleMemberBody(groups)
assertJSONEquivalent(t, body, map[string]interface{}{
"users": []interface{}{"ou_a", "ou_b"},
"departments": []interface{}{"od-a"},
"chats": []interface{}{"oc_a"},
})
}
func TestBuildRoleMemberGroupsRejectsEmpty(t *testing.T) {
_, err := buildRoleMemberGroups(" , ", "", "")
assertRoleValidationParams(t, err, "--users", "--departments", "--chats")
}
func TestBuildRoleMemberGroupsRejectsInvalidMemberIDWithSourceParam(t *testing.T) {
tests := []struct {
name string
users string
departments string
chats string
wantParam string
}{
{name: "users slash", users: "ou/bad", wantParam: "--users"},
{name: "users email", users: "alice@example.com", wantParam: "--users"},
{name: "users wrong prefix", users: "user_123", wantParam: "--users"},
{name: "users prefix only", users: "ou_", wantParam: "--users"},
{name: "departments wrong prefix", departments: "ou_user", wantParam: "--departments"},
{name: "departments prefix only", departments: "od-", wantParam: "--departments"},
{name: "legacy departments prefix", departments: "od_department", wantParam: "--departments"},
{name: "chats wrong prefix", chats: "od-department", wantParam: "--chats"},
{name: "chats prefix only", chats: "oc_", wantParam: "--chats"},
{
name: "departments",
departments: "od-bad value",
wantParam: "--departments",
},
{
name: "chats",
chats: "oc?bad",
wantParam: "--chats",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := buildRoleMemberGroups(tt.users, tt.departments, tt.chats)
assertRoleValidationParam(t, err, tt.wantParam)
})
}
}
func TestBuildRoleMemberGroupsRejectsMoreThanMax(t *testing.T) {
users := make([]string, maxRoleMembers+1)
for i := range users {
users[i] = "ou_test"
}
_, err := buildRoleMemberGroups(strings.Join(users, ","), "", "")
assertRoleValidationParams(t, err, "--users")
}
func TestBuildRoleMemberGroupsRejectsMoreThanMaxOnlyChats(t *testing.T) {
chats := make([]string, maxRoleMembers+1)
for i := range chats {
chats[i] = "oc_test"
}
_, err := buildRoleMemberGroups("", "", strings.Join(chats, ","))
problem := assertRoleValidationParams(t, err, "--chats")
if !strings.Contains(problem.Message, "role members cannot exceed 100") {
t.Fatalf("message = %q, want role members limit", problem.Message)
}
if !strings.Contains(problem.Hint, "does not split") || !strings.Contains(problem.Hint, "atomic request") {
t.Fatalf("hint = %q, want no automatic batching guidance", problem.Hint)
}
}
func TestBuildRoleMemberGroupsOverflowNamesEveryContributingFlag(t *testing.T) {
users := strings.TrimSuffix(strings.Repeat("ou_user,", 60), ",")
chats := strings.TrimSuffix(strings.Repeat("oc_chat,", 41), ",")
_, err := buildRoleMemberGroups(users, "", chats)
assertRoleValidationParams(t, err, "--users", "--chats")
}
func TestRoleMemberKindsAreCompleteAndStable(t *testing.T) {
want := []roleMemberKind{
{memberType: "user", dataKey: "users", flagName: "--users", prefix: "ou_"},
{memberType: "department", dataKey: "departments", flagName: "--departments", prefix: "od-"},
{memberType: "chat", dataKey: "chats", flagName: "--chats", prefix: "oc_"},
}
if len(roleMemberKinds) != len(want) {
t.Fatalf("roleMemberKinds = %#v, want %#v", roleMemberKinds, want)
}
for index := range want {
if roleMemberKinds[index] != want[index] {
t.Fatalf("roleMemberKinds[%d] = %#v, want %#v", index, roleMemberKinds[index], want[index])
}
}
}
func TestRoleDisplayValueSanitizesAndFlattens(t *testing.T) {
got := roleDisplayValue(" Admin\n\x1b[31mred\x1b[0m\tvalue ")
if got != "Admin red value" {
t.Fatalf("roleDisplayValue() = %q, want flattened safe text", got)
}
}
func TestRoleNextPageToken(t *testing.T) {
if got := roleNextPageToken(40, 20, true); got != "60" {
t.Fatalf("roleNextPageToken(hasMore) = %q, want 60", got)
}
if got := roleNextPageToken(40, 20, false); got != "" {
t.Fatalf("roleNextPageToken(!hasMore) = %q, want empty", got)
}
}
func TestWithRoleErrorHintUsesDocumentedRecoveryAndPreservesEnvelope(t *testing.T) {
tests := []struct {
name string
code int
operation roleErrorOperation
wantHint string
forbid string
}{
{name: "invalid parameters", code: roleErrInvalidParameters, operation: roleOperationList, wantHint: roleAppHint},
{name: "administrator required", code: roleErrAdminRequired, operation: roleOperationList, wantHint: "app administrator"},
{name: "administrator or developer required", code: roleErrManagerRequired, operation: roleOperationGet, wantHint: "administrator or app developer"},
{name: "invalid create role id", code: roleErrInvalidRoleID, operation: roleOperationCreate, wantHint: "omit --role-id"},
{name: "role missing", code: roleErrRoleNotFound, operation: roleOperationGet, wantHint: "+role-list"},
{name: "stale match role", code: roleErrRoleNotFound, operation: roleOperationMatchList, wantHint: "may no longer be valid", forbid: "--role-id"},
{name: "duplicate role id", code: roleErrRoleAlreadyExists, operation: roleOperationCreate, wantHint: "different --role-id"},
{name: "role limit", code: roleErrRoleLimitExceeded, operation: roleOperationCreate, wantHint: "delete an unused app role"},
{name: "invalid role name", code: roleErrInvalidRoleName, operation: roleOperationUpdate, wantHint: "adjust --name"},
{name: "invalid role description", code: roleErrInvalidRoleDescription, operation: roleOperationUpdate, wantHint: "adjust --description"},
{name: "unsupported member type", code: roleErrUnsupportedMemberType, operation: roleOperationMemberList, wantHint: "user, department, or chat"},
{name: "invalid member id", code: roleErrInvalidMemberID, operation: roleOperationMemberAdd, wantHint: "member IDs"},
{name: "invalid match target", code: roleErrInvalidMemberID, operation: roleOperationMatchList, wantHint: "--user-id", forbid: "--role-id"},
{name: "user quota", code: roleErrUserLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the users"},
{name: "department quota", code: roleErrDepartmentLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the departments"},
{name: "chat quota", code: roleErrChatLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the chats"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errclass.BuildAPIError(map[string]any{
"code": tt.code,
"msg": "role request failed",
"log_id": "log-role-hint",
}, errclass.ClassifyContext{Identity: "user"})
err = withRoleErrorHint(err, tt.operation)
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %#v, want typed problem", err)
}
if problem.Code != tt.code || problem.LogID != "log-role-hint" || problem.Retryable {
t.Fatalf("problem envelope changed: %+v", problem)
}
if !strings.Contains(problem.Hint, tt.wantHint) {
t.Fatalf("hint = %q, want substring %q", problem.Hint, tt.wantHint)
}
if tt.forbid != "" && strings.Contains(problem.Hint, tt.forbid) {
t.Fatalf("hint = %q, must not contain %q", problem.Hint, tt.forbid)
}
})
}
}
func TestWithRoleErrorHintPreservesServerDetail(t *testing.T) {
err := errclass.BuildAPIError(map[string]any{
"code": roleErrInvalidRoleName,
"msg": "invalid role name",
"error": map[string]any{
"details": []any{map[string]any{"value": "name exceeds the service limit"}},
},
}, errclass.ClassifyContext{Identity: "user"})
err = withRoleErrorHint(err, roleOperationCreate)
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %#v, want typed problem", err)
}
for _, want := range []string{"name exceeds the service limit", "adjust --name"} {
if !strings.Contains(problem.Hint, want) {
t.Fatalf("hint = %q, want %q", problem.Hint, want)
}
}
}
func TestWithRoleErrorHintPreservesAuthorizationDetail(t *testing.T) {
var err error = errs.NewPermissionError(errs.SubtypePermissionDenied, "administrator access required").
WithCode(roleErrAdminRequired).
WithHint("server detail: only owners may change this app")
err = withRoleErrorHint(err, roleOperationUpdate)
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %#v, want typed problem", err)
}
for _, want := range []string{"server detail: only owners", "ask an app administrator"} {
if !strings.Contains(problem.Hint, want) {
t.Fatalf("hint = %q, want %q", problem.Hint, want)
}
}
}

View File

@@ -0,0 +1,611 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"text/tabwriter"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsRoleMemberList lists members of an app role.
var AppsRoleMemberList = common.Shortcut{
Service: appsService,
Command: "+role-member-list",
Description: "List app role members",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +role-member-list --app-id <app_id> --role-id <role_id>",
"Example: lark-cli apps +role-member-list --app-id <app_id> --role-id <role_id> --member-type user",
"When only one member type is requested, pass --member-type user|department|chat instead of filtering the full response",
"--member-type returns only the selected member field; omitted fields are unknown, so omit the flag for pre/post-write baselines",
"--format table renders the CLI-native member_type/member_id table; this command has no --limit or --page-size flag",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
{Name: "member-type", Desc: "filter member type", Enum: []string{"user", "department", "chat"}},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleID(rctx); err != nil {
return err
}
_, err := buildRoleMemberListParams(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
// Validate already ran and called buildRoleMemberListParams; error is impossible here.
params, _ := buildRoleMemberListParams(rctx)
return common.NewDryRunAPI().
GET(roleMemberListURL(rctx)).
Desc("List app role members").
Params(params)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
params, err := buildRoleMemberListParams(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", roleMemberListURL(rctx), params, nil)
memberType, _ := params["member_type"].(string)
if shouldRetryRoleMemberListWithoutFilter(err, memberType) {
fmt.Fprintln(rctx.IO().ErrOut, "warning: the server rejected chat member filtering; retried without the filter and returned only the chats field. Omit --member-type for a complete member baseline.")
data, err = rctx.CallAPITyped("GET", roleMemberListURL(rctx), nil, nil)
}
if err != nil {
return withRoleErrorHint(err, roleOperationMemberList)
}
data, err = normalizeRoleMemberListData(data, memberType)
if err != nil {
return err
}
if memberType != "" {
fmt.Fprintf(
rctx.IO().ErrOut,
"warning: --member-type=%s returns only the selected member field; omitted member fields are unknown. Omit --member-type for a complete member baseline.\n",
memberType,
)
}
out := roleMemberListOutputData(rctx, data)
rctx.OutFormat(out, nil, func(w io.Writer) {
renderRoleMemberListPretty(w, data)
})
return nil
},
}
// AppsRoleMemberAdd adds members to an app role.
var AppsRoleMemberAdd = common.Shortcut{
Service: appsService,
Command: "+role-member-add",
Description: "Add app role members",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +role-member-add --app-id <app_id> --role-id <role_id> --users ou_x",
"Example: lark-cli apps +role-member-add --app-id <app_id> --role-id <role_id> --users ou_x,ou_y --departments od-x --chats oc_x",
"Resolve every name first, then add all resolved users (ou_), departments (od-), and chats (oc_) in one call using the three type-specific flags; if any resolution fails, stop without a partial write",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
{Name: "users", Desc: "comma-separated user open IDs; do not pass names or emails"},
{Name: "departments", Desc: "comma-separated open_department_id values"},
{Name: "chats", Desc: "comma-separated open_chat_id values"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleID(rctx); err != nil {
return err
}
_, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
// Validate already ran and called buildRoleMemberAddBody; error is impossible here.
body, _, _ := buildRoleMemberAddBody(rctx)
return common.NewDryRunAPI().
POST(roleMemberAddURL(rctx)).
Desc("Add app role members").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
body, _, err := buildRoleMemberAddBody(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", roleMemberAddURL(rctx), nil, body)
if err != nil {
return withRoleErrorHint(err, roleOperationMemberAdd)
}
data, err = normalizeRoleMemberMutationData(data)
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleMemberMutationPretty(w, data)
})
return nil
},
}
// AppsRoleMemberRemove removes members from an app role.
var AppsRoleMemberRemove = common.Shortcut{
Service: appsService,
Command: "+role-member-remove",
Description: "Remove app role members",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +role-member-remove --app-id <app_id> --role-id <role_id> --users ou_x --yes",
"Example: lark-cli apps +role-member-remove --app-id <app_id> --role-id <role_id> --all --yes",
"When the user names a member, resolve and verify that exact name before writing; if lookup fails, stop and never infer that the role's only current member is the target",
"--all clears members but does not delete the role; after a confirmed --all operation, use an unfiltered +role-member-list to verify users, departments, and chats are empty",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
{Name: "users", Desc: "comma-separated user open IDs; do not pass names or emails"},
{Name: "departments", Desc: "comma-separated open_department_id values"},
{Name: "chats", Desc: "comma-separated open_chat_id values"},
{Name: "all", Type: "bool", Desc: "remove all members from the role"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleID(rctx); err != nil {
return err
}
_, _, err := buildRoleMemberRemoveBody(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
// Validate already ran and called buildRoleMemberRemoveBody; error is impossible here.
body, _, _ := buildRoleMemberRemoveBody(rctx)
return common.NewDryRunAPI().
POST(roleMemberRemoveURL(rctx)).
Desc("Remove app role members").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
body, _, err := buildRoleMemberRemoveBody(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", roleMemberRemoveURL(rctx), nil, body)
if err != nil {
return withRoleErrorHint(err, roleOperationMemberRemove)
}
data, err = normalizeRoleMemberMutationData(data)
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleMemberMutationPretty(w, data)
})
return nil
},
}
// AppsRoleMatchList lists roles matching a user in an app.
var AppsRoleMatchList = common.Shortcut{
Service: appsService,
Command: "+role-match-list",
Description: "List app roles matching a user",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +role-match-list --app-id <app_id> --user-id <user_open_id>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "user-id", Desc: roleUserIDRequiredDesc, Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleAppID(rctx); err != nil {
return err
}
_, err := roleMatchTargetUserID(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
// Validate already ran and called buildRoleMatchListBody; error is impossible here.
body, _ := buildRoleMatchListBody(rctx)
return common.NewDryRunAPI().
POST(roleMatchListURL(rctx)).
Desc("List app role matches").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
body, err := buildRoleMatchListBody(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", roleMatchListURL(rctx), nil, body)
if err != nil {
return withRoleErrorHint(err, roleOperationMatchList)
}
out, err := normalizeRoleMatchListData(data)
if err != nil {
return err
}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderRoleMatchListPretty(w, common.GetSlice(out, "roles"))
})
return nil
},
}
func roleMemberListURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(roleMemberListPath,
validate.EncodePathSegment(roleAppID(rctx)),
validate.EncodePathSegment(roleID(rctx)),
)
}
func roleMemberAddURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(roleMemberAddPath,
validate.EncodePathSegment(roleAppID(rctx)),
validate.EncodePathSegment(roleID(rctx)),
)
}
func roleMemberRemoveURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(roleMemberRemovePath,
validate.EncodePathSegment(roleAppID(rctx)),
validate.EncodePathSegment(roleID(rctx)),
)
}
func roleMatchListURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(roleMatchListPath, validate.EncodePathSegment(roleAppID(rctx)))
}
func buildRoleMemberListParams(rctx *common.RuntimeContext) (map[string]interface{}, error) {
params := map[string]interface{}{}
if memberType := strings.TrimSpace(rctx.Str("member-type")); memberType != "" {
if _, ok := roleMemberKindForType(memberType); !ok {
return nil, appsValidationParamError("--member-type", "--member-type must be one of user, department, or chat").
WithHint("omit --member-type to list all member types")
}
params["member_type"] = memberType
}
return params, nil
}
func shouldRetryRoleMemberListWithoutFilter(err error, memberType string) bool {
if err == nil || memberType != "chat" {
return false
}
problem, ok := errs.ProblemOf(err)
if !ok {
return false
}
if problem.Code == roleErrUnsupportedMemberType || problem.Code == 400004040 {
return true
}
return problem.Code == 2 && strings.Contains(strings.ToLower(problem.Message), "member_type")
}
func normalizeRoleMemberListData(data map[string]interface{}, memberType string) (map[string]interface{}, error) {
if data == nil {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role member response data must be an object",
).WithHint("retry the complete member read; do not treat missing, null, or non-object data as an empty role")
}
out := map[string]interface{}{}
for k, v := range data {
out[k] = v
}
// The role service uses an exact empty data object when the requested member
// view is empty. For a filtered request, that proves only the selected group
// is empty; non-selected groups must remain omitted rather than being
// synthesized as empty.
if len(data) == 0 {
if memberType != "" {
kind, _ := roleMemberKindForType(memberType)
out[kind.dataKey] = []string{}
return out, nil
}
for _, kind := range roleMemberKinds {
out[kind.dataKey] = []string{}
}
return out, nil
}
if memberType != "" {
selectedKind, _ := roleMemberKindForType(memberType)
values, err := parseRoleMemberIDs(data, selectedKind)
if err != nil {
return nil, err
}
for _, kind := range roleMemberKinds {
if kind.memberType != memberType {
delete(out, kind.dataKey)
}
}
out[selectedKind.dataKey] = values
return out, nil
}
for _, kind := range roleMemberKinds {
values, err := parseRoleMemberIDs(data, kind)
if err != nil {
return nil, err
}
out[kind.dataKey] = values
}
return out, nil
}
func parseRoleMemberIDs(data map[string]interface{}, kind roleMemberKind) ([]string, error) {
raw, exists := data[kind.dataKey]
if !exists {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role member response is missing %s",
kind.dataKey,
).WithHint("retry the member operation; do not treat a missing member group as empty")
}
items, ok := raw.([]interface{})
if !ok {
if stringItems, stringOK := raw.([]string); stringOK {
items = make([]interface{}, len(stringItems))
for index, value := range stringItems {
items[index] = value
}
} else {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role member response field %s must be an array of strings",
kind.dataKey,
).WithHint("retry the member operation; do not use malformed member data as a permission baseline")
}
}
values := make([]string, 0, len(items))
for index, item := range items {
value, ok := item.(string)
value = strings.TrimSpace(value)
if !ok || value == "" || !strings.HasPrefix(value, kind.prefix) || len(value) == len(kind.prefix) {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role member response field %s contains an invalid ID at index %d",
kind.dataKey,
index,
).WithHint("retry the member operation; expected open IDs with the documented member-type prefix")
}
values = append(values, value)
}
return values, nil
}
func normalizeRoleMemberMutationData(data map[string]interface{}) (map[string]interface{}, error) {
if data == nil {
return nil, nil
}
out := map[string]interface{}{}
for key, value := range data {
out[key] = value
}
for _, kind := range roleMemberKinds {
if _, exists := data[kind.dataKey]; !exists {
continue
}
values, err := parseRoleMemberIDs(data, kind)
if err != nil {
return nil, err
}
out[kind.dataKey] = values
}
return out, nil
}
func buildRoleMemberAddBody(rctx *common.RuntimeContext) (map[string]interface{}, roleMemberGroups, error) {
groups, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats"))
if err != nil {
return nil, groups, err
}
return buildRoleMemberBody(groups), groups, nil
}
func buildRoleMemberRemoveBody(rctx *common.RuntimeContext) (map[string]interface{}, roleMemberGroups, error) {
if rctx.Bool("all") {
if hasExplicitRoleMemberFlags(rctx) {
return nil, roleMemberGroups{}, appsValidationError("--all cannot be used with --users, --departments, or --chats").
WithParams(roleMemberRemoveConflictParams(rctx)...).
WithHint("use --all by itself to clear every member, or pass explicit member IDs without --all")
}
return map[string]interface{}{"all": true}, roleMemberGroups{}, nil
}
if !hasExplicitRoleMemberFlags(rctx) {
reason := "provide member IDs or use --all"
return nil, roleMemberGroups{}, appsValidationError("specify members to remove with --users/--departments/--chats, or use --all to clear every member").
WithParams(
appsInvalidParam("--users", reason),
appsInvalidParam("--departments", reason),
appsInvalidParam("--chats", reason),
appsInvalidParam("--all", reason),
).
WithHint("pass specific member IDs (e.g. --users ou_x), or use --all to remove all members")
}
groups, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats"))
if err != nil {
return nil, groups, err
}
return buildRoleMemberBody(groups), groups, nil
}
func roleMemberRemoveConflictParams(rctx *common.RuntimeContext) []errs.InvalidParam {
reason := "cannot be combined with --all"
params := []errs.InvalidParam{appsInvalidParam("--all", "cannot be combined with explicit member flags")}
for _, kind := range roleMemberKinds {
if strings.TrimSpace(rctx.Str(strings.TrimPrefix(kind.flagName, "--"))) != "" {
params = append(params, appsInvalidParam(kind.flagName, reason))
}
}
return params
}
func hasExplicitRoleMemberFlags(rctx *common.RuntimeContext) bool {
return strings.TrimSpace(rctx.Str("users")) != "" ||
strings.TrimSpace(rctx.Str("departments")) != "" ||
strings.TrimSpace(rctx.Str("chats")) != ""
}
func buildRoleMatchListBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
targetUserID, err := roleMatchTargetUserID(rctx)
if err != nil {
return nil, err
}
return map[string]interface{}{"target_user_id": targetUserID}, nil
}
func roleMatchTargetUserID(rctx *common.RuntimeContext) (string, error) {
raw := strings.TrimSpace(rctx.Str("user-id"))
if raw == "" {
return "", appsValidationParamError("--user-id", "--user-id is required").
WithHint("resolve the user to open_id first, then pass --user-id <open_id>")
}
if err := validateMemberID(raw, "--user-id"); err != nil {
return "", err
}
return raw, nil
}
func roleMemberListOutputData(rctx *common.RuntimeContext, data map[string]interface{}) interface{} {
switch rctx.Format {
case "table", "csv", "ndjson":
return roleMemberRows(data)
default:
return data
}
}
func roleMemberRows(data map[string]interface{}) []interface{} {
rows := []interface{}{}
addRows := func(memberType string, values []string) {
for _, value := range values {
rows = append(rows, map[string]interface{}{
"member_type": memberType,
"member_id": value,
})
}
}
for _, kind := range roleMemberKinds {
addRows(kind.memberType, roleIDValues(data[kind.dataKey]))
}
return rows
}
func normalizeRoleMatchListData(data map[string]interface{}) (map[string]interface{}, error) {
rawRoles, exists := data["roles"]
roles, ok := rawRoles.([]interface{})
if !exists || !ok {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role match response field roles must be an array",
).WithHint("retry the user-role lookup; do not treat a missing or malformed roles field as no matches")
}
if err := validateRoleCollection(roles, "role match response field roles"); err != nil {
return nil, err
}
out := map[string]interface{}{}
for k, v := range data {
out[k] = v
}
out["roles"] = roles
return out, nil
}
func renderRoleMemberListPretty(w io.Writer, data map[string]interface{}) {
renderRoleMemberGroupsPretty(w, data)
}
func renderRoleMemberGroupsPretty(w io.Writer, data map[string]interface{}) {
for _, kind := range roleMemberKinds {
value, exists := data[kind.dataKey]
if !exists {
continue
}
renderRoleMemberSection(w, kind.dataKey, roleIDValues(value))
}
}
func renderRoleMemberMutationPretty(w io.Writer, data map[string]interface{}) {
renderedGroup := false
for _, kind := range roleMemberKinds {
value, exists := data[kind.dataKey]
if !exists {
continue
}
renderRoleMemberSection(w, kind.dataKey, roleIDValues(value))
renderedGroup = true
}
if !renderedGroup {
fmt.Fprintln(w, "Role member update accepted; use +role-member-list to verify current members.")
}
}
func renderRoleMemberSection(w io.Writer, label string, values []string) {
if len(values) == 0 {
fmt.Fprintf(w, "%s: []\n", label)
return
}
fmt.Fprintf(w, "%s:\n", label)
for _, value := range values {
fmt.Fprintf(w, " - %s\n", roleDisplayValue(value))
}
}
func roleIDValues(value interface{}) []string {
switch items := value.(type) {
case []string:
out := make([]string, 0, len(items))
for _, item := range items {
if item = strings.TrimSpace(item); item != "" {
out = append(out, item)
}
}
return out
case []interface{}:
out := make([]string, 0, len(items))
for _, item := range items {
v, ok := item.(string)
if ok && strings.TrimSpace(v) != "" {
out = append(out, strings.TrimSpace(v))
}
}
return out
default:
return nil
}
}
func renderRoleMatchListPretty(w io.Writer, items []interface{}) {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ROLE ID\tNAME\tDESCRIPTION")
for _, item := range items {
role, ok := item.(map[string]interface{})
if !ok {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%s\n",
roleDisplayValue(firstNonEmpty(common.GetString(role, "role_id"), common.GetString(role, "id"))),
roleDisplayValue(common.GetString(role, "name")),
roleDisplayValue(common.GetString(role, "description")),
)
}
_ = tw.Flush()
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,15 @@ func Shortcuts() []common.Shortcut {
AppsList,
AppsAccessScopeSet,
AppsAccessScopeGet,
AppsRoleList,
AppsRoleGet,
AppsRoleCreate,
AppsRoleUpdate,
AppsRoleDelete,
AppsRoleMemberList,
AppsRoleMemberAdd,
AppsRoleMemberRemove,
AppsRoleMatchList,
AppsHTMLPublish,
AppsInit,
AppsReleaseCreate,

View File

@@ -21,11 +21,12 @@ import (
// - 5 sessioncreate/list/get/stop/chat+ 1 session-messages-list
// - 8 openapi-keylist/get/create/update/enable/disable/delete/reset
// - 3 plugininstall/uninstall/list
// - 6 automationlist/get/create/update/enable/disable= 70。
func TestAppsShortcuts_Returns70(t *testing.T) {
// - 6 automationlist/get/create/update/enable/disable
// - 9 rolerole CRUD + role-member list/add/remove + role-match-list= 79。
func TestAppsShortcuts_Returns79(t *testing.T) {
got := Shortcuts()
if len(got) != 70 {
t.Fatalf("Shortcuts() returned %d entries, want 70", len(got))
if len(got) != 79 {
t.Fatalf("Shortcuts() returned %d entries, want 79", len(got))
}
}
@@ -89,6 +90,34 @@ func TestAppsShortcuts_IncludesSessionCommands(t *testing.T) {
}
}
// 确认 role 管理命令都已挂载,避免实现存在但 shortcut 漏注册。
func TestAppsShortcuts_IncludesRoleCommands(t *testing.T) {
want := map[string]bool{
"+role-list": false,
"+role-get": false,
"+role-create": false,
"+role-update": false,
"+role-delete": false,
"+role-member-list": false,
"+role-member-add": false,
"+role-member-remove": false,
"+role-match-list": false,
}
for _, sc := range Shortcuts() {
if _, ok := want[sc.Command]; ok {
want[sc.Command] = true
if sc.Hidden {
t.Errorf("%s must be visible", sc.Command)
}
}
}
for cmd, found := range want {
if !found {
t.Errorf("Shortcuts() missing %s", cmd)
}
}
}
// TestAppsGitCredentialHelper_IsNotAShortcut 确认 git credential helper 不作为 shortcut 暴露。
func TestAppsGitCredentialHelper_IsNotAShortcut(t *testing.T) {
for _, shortcut := range Shortcuts() {

View File

@@ -4,9 +4,11 @@
package base
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
@@ -676,6 +678,145 @@ func TestBaseDashboardBlockCreate_InvalidRollup(t *testing.T) {
}
}
// TestBaseDashboardBlockCreate_IllegalSortOrderType guards against a P1 where a
// non-string sort.order (123 / null / false) was silently coerced to "asc" and
// created a block with a tampered sort. A present-but-illegal order must now
// surface a typed validation error, never a silent default.
func TestBaseDashboardBlockCreate_IllegalSortOrderType(t *testing.T) {
for _, tc := range []struct {
name string
order string // raw JSON literal for the order value
}{
{"number", "123"},
{"null", "null"},
{"bool", "false"},
} {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
dc := `{"table_name":"T","series":[{"field_name":"金额","rollup":"SUM"}],` +
`"group_by":[{"field_name":"状态","mode":"integrated","sort":{"type":"group","order":` + tc.order + `}}]}`
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1",
"--name", "Bad", "--type", "column", "--data-config", dc}
err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout)
if err == nil {
t.Fatalf("expected validation error for order=%s, got nil (stdout=%s)", tc.order, stdout.String())
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if ve.Category != errs.CategoryValidation || ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("category=%q subtype=%q, want validation/invalid_argument", ve.Category, ve.Subtype)
}
if ve.Param != "--data-config" {
t.Fatalf("param=%q, want --data-config", ve.Param)
}
if !strings.Contains(ve.Error(), "sort.order") {
t.Fatalf("error should name sort.order, got: %v", ve)
}
})
}
}
// TestBaseDashboardBlockCreate_MissingSortOrder pins the full create-path behavior
// when sort.order is absent: group/view are normalized to order:"asc" and succeed
// (matching the documented auto-fill), while value has no safe default and must
// surface a typed validation error. These run end-to-end (Validate → normalize →
// validate), so reverting the normalize/validate change flips a case and fails.
func TestBaseDashboardBlockCreate_MissingSortOrder(t *testing.T) {
dc := func(sortType string) string {
return `{"table_name":"T","series":[{"field_name":"金额","rollup":"SUM"}],` +
`"group_by":[{"field_name":"状态","mode":"integrated","sort":{"type":"` + sortType + `"}}]}`
}
// group / view: absent order is auto-filled with "asc" and the request goes through.
for _, sortType := range []string{"group", "view"} {
t.Run(sortType+" defaults to asc", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1",
"--name", "OK", "--type", "column", "--data-config", dc(sortType),
"--dry-run", "--format", "pretty"}
if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"order":"asc"`) {
t.Fatalf("expected normalized order:asc for type=%s, stdout=%s", sortType, got)
}
})
}
// value: no meaningful default direction, so a missing order is a typed error.
t.Run("value requires explicit order", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1",
"--name", "Bad", "--type", "column", "--data-config", dc("value")}
err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout)
if err == nil {
t.Fatalf("expected validation error for value sort missing order, got nil (stdout=%s)", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected validation/invalid_argument problem, got %T %v", err, err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--data-config" {
t.Fatalf("expected param --data-config, got %T %v", err, err)
}
if !strings.Contains(ve.Error(), "sort.order 缺失") {
t.Fatalf("error should report missing order, got: %v", ve)
}
})
}
// TestNormalizeDataConfigSortOrder pins the normalization contract for sort.order:
// only a truly absent key gets the "asc" default; a present illegal value is left
// untouched so validation can reject it; a valid string is lower-cased.
func TestNormalizeDataConfigSortOrder(t *testing.T) {
sortOf := func(cfg map[string]interface{}) map[string]interface{} {
gb := cfg["group_by"].([]interface{})
return gb[0].(map[string]interface{})["sort"].(map[string]interface{})
}
newCfg := func(sort map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"table_name": "T",
"series": []interface{}{map[string]interface{}{"field_name": "v", "rollup": "sum"}},
"group_by": []interface{}{map[string]interface{}{"field_name": "g", "sort": sort}},
}
}
t.Run("absent order defaults to asc for group", func(t *testing.T) {
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "group"}))
if got := sortOf(out)["order"]; got != "asc" {
t.Fatalf("order=%v, want asc", got)
}
})
t.Run("absent order not defaulted for value", func(t *testing.T) {
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "value"}))
if _, has := sortOf(out)["order"]; has {
t.Fatalf("value sort must not get a defaulted order: %v", sortOf(out))
}
})
t.Run("valid string lower-cased", func(t *testing.T) {
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "group", "order": "DESC"}))
if got := sortOf(out)["order"]; got != "desc" {
t.Fatalf("order=%v, want desc", got)
}
})
t.Run("illegal number not coerced", func(t *testing.T) {
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "group", "order": float64(123)}))
if got := sortOf(out)["order"]; got != float64(123) {
t.Fatalf("order=%v (type %T), want untouched 123", got, got)
}
})
t.Run("illegal nil not coerced", func(t *testing.T) {
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "view", "order": nil}))
got, has := sortOf(out)["order"]
if !has || got != nil {
t.Fatalf("order=%v has=%v, want present nil (untouched)", got, has)
}
})
}
// ── Text Block Tests ────────────────────────────────────────────────
// TestBaseDashboardBlockExecuteCreate_TextType tests creating text blocks with markdown content.

View File

@@ -117,6 +117,14 @@ func TestDryRunRecordOps(t *testing.T) {
)
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
listFieldNamesAliasRT := newBaseTestRuntimeWithSlices(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"field-names": {"Name", "Age"}},
nil,
map[string]int{"limit": 20},
)
assertDryRunContains(t, dryRunRecordList(ctx, listFieldNamesAliasRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "limit=20", "field_id=Name", "field_id=Age")
filteredListRT := newBaseTestRuntimeWithArrays(
map[string]string{
"base-token": "app_x",

View File

@@ -1296,6 +1296,29 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list field names alias", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{"rec_alias"},
"data": []interface{}{[]interface{}{"Alice", 18}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-names", "Name,Age", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_alias"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1320,6 +1343,30 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list json alias", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name"},
"field_id_list": []interface{}{"fld_name"},
"record_id_list": []interface{}{"rec_alias"},
"data": []interface{}{[]interface{}{"Carol"}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Carol"`) || !strings.Contains(got, `"rec_alias"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list markdown format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1576,6 +1623,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list field ids and field names alias are mutually exclusive", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "Name", "--field-names", "Age"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--field-id and --field-names are mutually exclusive") {
t.Fatalf("err=%v", err)
}
})
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)

View File

@@ -28,6 +28,14 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool
}
func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, stringArrayFlags, nil, boolFlags, intFlags)
}
func newBaseTestRuntimeWithSlices(stringFlags map[string]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, nil, stringSliceFlags, boolFlags, intFlags)
}
func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, stringArrayFlags map[string][]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
for name := range stringFlags {
cmd.Flags().String(name, "", "")
@@ -35,6 +43,9 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag
for name := range stringArrayFlags {
cmd.Flags().StringArray(name, nil, "")
}
for name := range stringSliceFlags {
cmd.Flags().StringSlice(name, nil, "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
@@ -50,6 +61,11 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag
_ = cmd.Flags().Set(name, value)
}
}
for name, values := range stringSliceFlags {
for _, value := range values {
_ = cmd.Flags().Set(name, value)
}
}
for name, value := range boolFlags {
if value {
_ = cmd.Flags().Set(name, "true")
@@ -545,6 +561,8 @@ func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
"not table_id or field_id",
"dashboard-block-data-config.md as the SSOT",
"do not invent data_config from natural language",
"set the intended group_by.sort in the initial create request",
"do not create first and then issue a second update",
"sequentially",
},
},
@@ -825,6 +843,7 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
"may use null for empty cells",
"use +field-list to confirm real writable fields",
"Batch create supports max 200 rows per call",
"do not immediately +record-list the same table",
"CellValue happy path: text/phone/url",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
"lark-base-cell-value.md",

View File

@@ -23,7 +23,7 @@ var BaseDashboardArrange = common.Shortcut{
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
},
Tips: []string{
"Server-side smart layout is not deterministic or position-specific; use only when the user asks to arrange or beautify a dashboard.",
"Server-side smart layout is not deterministic or position-specific; use only when the user asks to arrange or beautify a dashboard, or to tidy up a dashboard created from scratch in this session.",
},
DryRun: dryRunDashboardArrange,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -27,7 +27,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
{Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read dashboard-block-data-config.md before creating.", Required: true},
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation and normalization; send data_config as-is"},
},
Tips: []string{
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
@@ -35,6 +35,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
"Before creating data-backed blocks, use +table-list and +field-list to confirm real table and field names.",
"data_config uses table and field names, not table_id or field_id.",
"Read dashboard-block-data-config.md as the SSOT for chart templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.",
"For funnel/stage charts backed by ordered helper data, set the intended group_by.sort in the initial create request; do not create first and then issue a second update just to fix sorting.",
"Record the returned block_id; block update/delete/get-data commands need it.",
"Create dashboard blocks sequentially; do not parallelize multiple block creates for the same dashboard.",
},

View File

@@ -20,6 +20,7 @@ var BaseDashboardBlockGetData = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
blockIDFlag(true),
{Name: "dashboard-id", Desc: "hidden compatibility flag accepted by dashboard block commands; ignored by get-data", Hidden: true},
},
Tips: []string{
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",

View File

@@ -26,7 +26,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{
{Name: "name", Desc: "new block name"},
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation and normalization; send data_config as-is"},
},
Tips: []string{
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --name "Total Sales"`,

View File

@@ -1038,11 +1038,23 @@ func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} {
m["mode"] = strings.ToLower(strings.TrimSpace(md))
}
if sub, ok := m["sort"].(map[string]interface{}); ok {
sortType := ""
if t, ok := sub["type"].(string); ok {
sub["type"] = strings.ToLower(strings.TrimSpace(t))
sortType = strings.ToLower(strings.TrimSpace(t))
sub["type"] = sortType
}
if o, ok := sub["order"].(string); ok {
sub["order"] = strings.ToLower(strings.TrimSpace(o))
// Only lowercase a string order; leave a present-but-non-string
// order untouched so validateBlockDataConfig can reject it
// instead of it being silently coerced below.
_, hasOrderKey := sub["order"]
orderStr, orderIsString := sub["order"].(string)
if orderIsString {
sub["order"] = strings.ToLower(strings.TrimSpace(orderStr))
}
// Default only when the order key is truly absent. A present
// key (even an illegal type/value) must survive to validation.
if !hasOrderKey && (sortType == "group" || sortType == "view") {
sub["order"] = "asc"
}
m["sort"] = sub
}
@@ -1126,12 +1138,16 @@ func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []str
if sub, ok := m["sort"].(map[string]interface{}); ok {
t, _ := sub["type"].(string)
t = strings.ToLower(strings.TrimSpace(t))
o, _ := sub["order"].(string)
o = strings.ToLower(strings.TrimSpace(o))
if t != "group" && t != "value" && t != "view" {
errs = append(errs, fmt.Sprintf("group_by[%d].sort.type 仅支持 group|value|view", i))
}
if o != "asc" && o != "desc" {
orderRaw, hasOrder := sub["order"]
o, orderIsString := orderRaw.(string)
o = strings.ToLower(strings.TrimSpace(o))
switch {
case !hasOrder:
errs = append(errs, fmt.Sprintf("group_by[%d].sort.order 缺失sort 存在时必须设置 order 为 asc 或 desc例如 \"sort\":{\"type\":\"group\",\"order\":\"asc\"}", i))
case !orderIsString || (o != "asc" && o != "desc"):
errs = append(errs, fmt.Sprintf("group_by[%d].sort.order 仅支持 asc|desc", i))
}
}
@@ -1178,5 +1194,5 @@ func formatDataConfigErrors(problems []string) error {
if len(problems) == 0 {
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- ")).WithParam("--data-config")
}

View File

@@ -25,6 +25,7 @@ var BaseRecordBatchCreate = common.Shortcut{
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch create supports max 200 rows per call.",
"After batch-creating known helper rows, use the returned record IDs and your submitted rows; do not immediately +record-list the same table unless you need server-normalized formula/lookup values or failure diagnosis.",
"Use the record-batch-create guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -21,6 +21,7 @@ var BaseRecordList = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
recordListFieldRefFlag(),
recordListFieldNamesAliasFlag(),
recordListViewRefFlag(),
recordFilterFlag(),
recordSortFlag(),
@@ -43,6 +44,9 @@ var BaseRecordList = common.Shortcut{
"Use --field-id repeatedly to keep output small and aligned with the task.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordListFieldAlias(runtime); err != nil {
return err
}
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
@@ -75,6 +79,15 @@ func recordListFieldRefFlag() common.Flag {
return flag
}
func recordListFieldNamesAliasFlag() common.Flag {
return common.Flag{
Name: "field-names",
Type: "string_slice",
Desc: "hidden alias for --field-id; accepts comma-separated field names",
Hidden: true,
}
}
func recordListViewRefFlag() common.Flag {
flag := viewRefFlag(false)
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
@@ -89,3 +102,10 @@ func recordReadFormatFlag() common.Flag {
Desc: "output format: markdown (default) | json",
}
}
func validateRecordListFieldAlias(runtime *common.RuntimeContext) error {
if runtime.Changed("field-id") && runtime.Changed("field-names") {
return baseFlagErrorf("--field-id and --field-names are mutually exclusive; use --field-id")
}
return nil
}

View File

@@ -376,6 +376,9 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
}
func recordListFields(runtime *common.RuntimeContext) []string {
if runtime.Changed("field-names") {
return runtime.StrSlice("field-names")
}
return runtime.StrArray("field-id")
}

View File

@@ -22,15 +22,23 @@ func GetString(m map[string]interface{}, keys ...string) string {
// GetFloat safely extracts a float64 (the default JSON number type).
func GetFloat(m map[string]interface{}, keys ...string) float64 {
f, _ := GetFloatOK(m, keys...)
return f
}
// GetFloatOK extracts a float64 and reports whether the field was present and
// numeric. Use it for protocol discriminators where silently turning malformed
// input into zero could misclassify a response as successful.
func GetFloatOK(m map[string]interface{}, keys ...string) (float64, bool) {
if len(keys) == 0 {
return 0
return 0, false
}
v := navigate(m, keys[:len(keys)-1])
if v == nil {
return 0
return 0, false
}
f, _ := util.ToFloat64(v[keys[len(keys)-1]])
return f
f, ok := util.ToFloat64(v[keys[len(keys)-1]])
return f, ok
}
// GetInt safely extracts an int, accepting both in-memory ints and JSON-style float64 values.

View File

@@ -64,6 +64,24 @@ func TestGetFloat(t *testing.T) {
}
}
func TestGetFloatOKDistinguishesMalformedValuesFromZero(t *testing.T) {
t.Parallel()
m := map[string]interface{}{
"zero": float64(0),
"null": nil,
"string": "0",
}
if got, ok := GetFloatOK(m, "zero"); !ok || got != 0 {
t.Fatalf("GetFloatOK(zero) = (%v, %t), want (0, true)", got, ok)
}
for _, key := range []string{"null", "string", "missing"} {
if got, ok := GetFloatOK(m, key); ok || got != 0 {
t.Fatalf("GetFloatOK(%s) = (%v, %t), want (0, false)", key, got, ok)
}
}
}
func TestGetInt(t *testing.T) {
m := map[string]interface{}{
"count": 42,

View File

@@ -32,14 +32,15 @@ type driveDeleteSpec struct {
FileType string
}
// DriveDelete deletes a Drive file or folder and handles the async task
// polling required by folder deletes.
// DriveDelete deletes a Drive file or folder with async=true. When the response
// includes a task_id, it performs a bounded task_check poll before returning a
// resume command for unfinished tasks.
var DriveDelete = common.Shortcut{
Service: "drive",
Command: "+delete",
Description: "Delete a file or folder in Drive",
Risk: "high-risk-write",
Scopes: []string{"space:document:delete"},
Scopes: []string{"space:document:delete", "drive:drive.metadata:readonly"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "file or folder token to delete", Required: true},
@@ -63,13 +64,11 @@ var DriveDelete = common.Shortcut{
dry.DELETE("/open-apis/drive/v1/files/:file_token").
Desc("[1] Delete file/folder").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"type": spec.FileType})
Params(driveDeleteParams(spec))
if spec.FileType == "folder" {
dry.GET("/open-apis/drive/v1/files/task_check").
Desc("[2] Poll async task status (for folder delete)").
Params(driveTaskCheckParams("<task_id>"))
}
dry.GET("/open-apis/drive/v1/files/task_check").
Desc("[2] Poll async delete task status when task_id is returned").
Params(driveTaskCheckParams("<task_id>"))
return dry
},
@@ -84,56 +83,59 @@ var DriveDelete = common.Shortcut{
data, err := runtime.CallAPITyped(
"DELETE",
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(spec.FileToken)),
map[string]interface{}{"type": spec.FileType},
driveDeleteParams(spec),
nil,
)
if err != nil {
return err
}
if spec.FileType == "folder" {
taskID := common.GetString(data, "task_id")
if taskID == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "delete folder returned no task_id")
}
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete is async, polling task %s...\n", taskID)
status, ready, err := pollDriveTaskCheck(runtime, taskID)
if err != nil {
return err
}
out := map[string]interface{}{
"task_id": taskID,
"status": status.StatusLabel(),
taskID := common.GetString(data, "task_id")
if taskID == "" {
runtime.Out(map[string]interface{}{
"deleted": true,
"file_token": spec.FileToken,
"type": spec.FileType,
"ready": ready,
}
if ready {
out["deleted"] = true
}
if !ready {
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
runtime.Out(out, nil)
}, nil)
return nil
}
runtime.Out(map[string]interface{}{
"deleted": true,
fmt.Fprintf(runtime.IO().ErrOut, "Delete is async, polling task %s...\n", taskID)
status, ready, err := pollDriveTaskCheck(runtime, taskID)
if err != nil {
return err
}
out := map[string]interface{}{
"task_id": taskID,
"status": status.StatusLabel(),
"file_token": spec.FileToken,
"type": spec.FileType,
}, nil)
"ready": ready,
}
if ready {
out["deleted"] = true
}
if !ready {
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
fmt.Fprintf(runtime.IO().ErrOut, "Delete task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
runtime.Out(out, nil)
return nil
},
}
func driveDeleteParams(spec driveDeleteSpec) map[string]interface{} {
return map[string]interface{}{
"type": spec.FileType,
"async": true,
}
}
func validateDriveDeleteSpec(spec driveDeleteSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
"testing"
@@ -32,16 +33,16 @@ func TestValidateDriveDeleteSpecRejectsWiki(t *testing.T) {
}
}
func TestDriveDeleteDryRunFolderIncludesTaskCheckParams(t *testing.T) {
func TestDriveDeleteDryRunIncludesAsyncAndTaskCheckParams(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +delete"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
if err := cmd.Flags().Set("file-token", "fld_src"); err != nil {
if err := cmd.Flags().Set("file-token", "docx_src"); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("type", "folder"); err != nil {
if err := cmd.Flags().Set("type", "docx"); err != nil {
t.Fatalf("set --type: %v", err)
}
@@ -71,14 +72,36 @@ func TestDriveDeleteDryRunFolderIncludesTaskCheckParams(t *testing.T) {
if got.API[0].Method != "DELETE" {
t.Fatalf("first method = %q, want DELETE", got.API[0].Method)
}
if got.API[0].Params["type"] != "folder" {
if got.API[0].Params["type"] != "docx" {
t.Fatalf("delete params = %#v", got.API[0].Params)
}
if got.API[0].Params["async"] != true {
t.Fatalf("delete params = %#v, want async=true", got.API[0].Params)
}
if got.API[1].Params["task_id"] != "<task_id>" {
t.Fatalf("task check params = %#v", got.API[1].Params)
}
}
func TestDriveDeleteScopesIncludeTaskCheckReadScope(t *testing.T) {
t.Parallel()
wantScopes := map[string]bool{
"space:document:delete": false,
"drive:drive.metadata:readonly": false,
}
for _, scope := range DriveDelete.Scopes {
if _, ok := wantScopes[scope]; ok {
wantScopes[scope] = true
}
}
for scope, seen := range wantScopes {
if !seen {
t.Fatalf("DriveDelete.Scopes missing %q: %#v", scope, DriveDelete.Scopes)
}
}
}
func TestDriveDeleteRequiresYes(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
@@ -97,6 +120,63 @@ func TestDriveDeleteRequiresYes(t *testing.T) {
}
func TestDriveDeleteFileSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/file_token_test",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_file_123"},
},
OnMatch: func(req *http.Request) {
query := req.URL.Query()
if got := query.Get("type"); got != "file" {
t.Errorf("delete query type=%q, want file", got)
}
if got := query.Get("async"); got != "true" {
t.Errorf("delete query async=%q, want true", got)
}
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "success"},
},
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("task_id"); got != "task_file_123" {
t.Errorf("task_check task_id=%q, want task_file_123", got)
}
},
})
err := mountAndRunDrive(t, DriveDelete, []string{
"+delete",
"--file-token", "file_token_test",
"--type", "file",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_file_123"`)) {
t.Fatalf("stdout missing task_id: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"deleted": true`)) {
t.Fatalf("stdout missing deleted=true: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) {
t.Fatalf("stdout missing ready=true: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"file_token": "file_token_test"`)) {
t.Fatalf("stdout missing file token: %s", stdout.String())
}
}
func TestDriveDeleteWithoutTaskIDFallsBackToSyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
@@ -117,23 +197,33 @@ func TestDriveDeleteFileSuccess(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"deleted": true`)) {
t.Fatalf("stdout missing deleted=true: %s", stdout.String())
for _, needle := range []string{
`"deleted": true`,
`"file_token": "file_token_test"`,
`"type": "file"`,
} {
if !bytes.Contains(stdout.Bytes(), []byte(needle)) {
t.Fatalf("stdout missing %q: %s", needle, stdout.String())
}
}
if !bytes.Contains(stdout.Bytes(), []byte(`"file_token": "file_token_test"`)) {
t.Fatalf("stdout missing file token: %s", stdout.String())
if bytes.Contains(stdout.Bytes(), []byte(`"task_id"`)) {
t.Fatalf("stdout should not include task_id for sync success fallback: %s", stdout.String())
}
}
func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
func TestDriveDeleteTaskCheckOutcomes(t *testing.T) {
tests := []struct {
name string
fileType string
fileToken string
taskCheckBody map[string]interface{}
wantErrContains string
wantStdout []string
}{
{
name: "success",
name: "docx success",
fileType: "docx",
fileToken: "docx_src",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "success"},
@@ -145,7 +235,9 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
},
},
{
name: "timeout",
name: "folder timeout",
fileType: "folder",
fileToken: "fld_src",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "process"},
@@ -157,15 +249,19 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
},
},
{
name: "failed",
name: "folder failed",
fileType: "folder",
fileToken: "fld_src",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "fail"},
},
wantErrContains: "folder task failed",
wantErrContains: "drive task failed",
},
{
name: "task_check error",
name: "docx task_check error",
fileType: "docx",
fileToken: "docx_src",
taskCheckBody: map[string]interface{}{
"code": 1061001,
"msg": "internal error",
@@ -179,7 +275,7 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/fld_src",
URL: "/open-apis/drive/v1/files/" + tt.fileToken,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_123"},
@@ -195,8 +291,8 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
err := mountAndRunDrive(t, DriveDelete, []string{
"+delete",
"--file-token", "fld_src",
"--type", "folder",
"--file-token", tt.fileToken,
"--type", tt.fileType,
"--yes",
"--as", "bot",
}, f, stdout)
@@ -222,3 +318,66 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
})
}
}
func TestDriveDeleteTimedOutTaskCanBeResumedWithTaskResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/fld_token_test",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_resume_123"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "process"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "success"},
},
})
withSingleDriveTaskCheckPoll(t)
err := mountAndRunDrive(t, DriveDelete, []string{
"+delete",
"--file-token", "fld_token_test",
"--type", "folder",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected delete error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
t.Fatalf("stdout missing ready=false: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_resume_123 --as bot"`)) {
t.Fatalf("stdout missing next_command: %s", stdout.String())
}
err = mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "task_check",
"--task-id", "task_resume_123",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected task_result error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_resume_123"`)) {
t.Fatalf("task_result stdout missing task_id: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) {
t.Fatalf("task_result stdout missing ready=true: %s", stdout.String())
}
}

View File

@@ -61,7 +61,7 @@ func validateDriveMoveSpec(spec driveMoveSpec) error {
}
// driveTaskCheckStatus represents the status payload returned by
// /drive/v1/files/task_check for async folder move/delete operations.
// /drive/v1/files/task_check for async Drive move/delete operations.
type driveTaskCheckStatus struct {
TaskID string
Status string
@@ -74,7 +74,7 @@ func (s driveTaskCheckStatus) Ready() bool {
func (s driveTaskCheckStatus) Failed() bool {
status := strings.TrimSpace(s.Status)
// The shared task_check endpoint is reused by multiple async flows. Some
// backends return "failed", while folder delete can return the shorter
// backends return "failed", while delete can return the shorter
// terminal state "fail".
return strings.EqualFold(status, "failed") || strings.EqualFold(status, "fail")
}
@@ -106,7 +106,7 @@ func driveTaskCheckParams(taskID string) map[string]interface{} {
}
// getDriveTaskCheckStatus fetches and validates the current state of an async
// folder move or delete task.
// Drive move or delete task.
func getDriveTaskCheckStatus(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return driveTaskCheckStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
@@ -159,11 +159,11 @@ func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTas
// Success and failure are terminal backend states. Any other value is kept
// as pending so the caller can decide whether to continue or resume later.
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Folder task completed successfully.\n")
fmt.Fprintf(runtime.IO().ErrOut, "Drive task completed successfully.\n")
return status, true, nil
}
if status.Failed() {
return status, false, errs.NewAPIError(errs.SubtypeServerError, "folder task failed")
return status, false, errs.NewAPIError(errs.SubtypeServerError, "drive task failed")
}
}

View File

@@ -15,12 +15,20 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const (
// These are fixed backend wire values for the Wiki-to-Drive task. Keep
// them unchanged even though the CLI scenario uses wiki_move_to_drive.
wikiMoveToDriveTaskType = "move_wiki_to_docs"
wikiMoveToDriveResultKey = "move_wiki_to_docs_result"
)
// DriveTaskResult exposes a unified read path for the async task types produced
// by Drive import, export, folder move/delete, wiki move, and wiki delete-space flows.
// by Drive import, export, file/folder move/delete, wiki move, wiki move-to-drive,
// and wiki delete flows.
var DriveTaskResult = common.Shortcut{
Service: "drive",
Command: "+task_result",
Description: "Poll async task result for import, export, drive move/delete, wiki move, wiki delete-space, or wiki delete-node operations",
Description: "Poll async task result for import, export, drive move/delete, wiki move, wiki move-to-drive, or wiki delete operations",
Risk: "read",
// This shortcut multiplexes multiple backend APIs with different scope
// requirements, so scenario-specific prechecks are handled in Validate.
@@ -28,22 +36,23 @@ var DriveTaskResult = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false},
{Name: "task-id", Desc: "async task ID (for drive task_check, wiki_move, wiki_delete_space, or wiki_delete_node tasks)", Required: false},
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, wiki_delete_space, or wiki_delete_node", Required: true},
{Name: "task-id", Desc: "async task ID (for drive task_check and all wiki task scenarios)", Required: false},
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, wiki_move_to_drive, wiki_delete_space, or wiki_delete_node", Required: true},
{Name: "file-token", Desc: "source document token used for export task status lookup", Required: false},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
scenario := strings.ToLower(runtime.Str("scenario"))
validScenarios := map[string]bool{
"import": true,
"export": true,
"task_check": true,
"wiki_move": true,
"wiki_delete_space": true,
"wiki_delete_node": true,
"import": true,
"export": true,
"task_check": true,
"wiki_move": true,
"wiki_move_to_drive": true,
"wiki_delete_space": true,
"wiki_delete_node": true,
}
if !validScenarios[scenario] {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario).WithParam("--scenario")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_move_to_drive, wiki_delete_space, wiki_delete_node", scenario).WithParam("--scenario")
}
// Validate required params based on scenario
@@ -55,7 +64,7 @@ var DriveTaskResult = common.Shortcut{
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--ticket")
}
case "task_check", "wiki_move", "wiki_delete_space", "wiki_delete_node":
case "task_check", "wiki_move", "wiki_move_to_drive", "wiki_delete_space", "wiki_delete_node":
if runtime.Str("task-id") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required for %s scenario", scenario).WithParam("--task-id")
}
@@ -97,13 +106,18 @@ var DriveTaskResult = common.Shortcut{
Params(map[string]interface{}{"token": fileToken})
case "task_check":
dry.GET("/open-apis/drive/v1/files/task_check").
Desc("[1] Query move/delete folder task status").
Desc("[1] Query Drive file/folder move/delete task status").
Params(driveTaskCheckParams(taskID))
case "wiki_move":
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[1] Query wiki move task result").
Set("task_id", taskID).
Params(map[string]interface{}{"task_type": "move"})
case "wiki_move_to_drive":
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[1] Query wiki move-to-drive task result").
Set("task_id", taskID).
Params(map[string]interface{}{"task_type": wikiMoveToDriveTaskType})
case "wiki_delete_space":
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[1] Query wiki delete-space task result").
@@ -140,6 +154,8 @@ var DriveTaskResult = common.Shortcut{
result, err = queryTaskCheck(runtime, taskID)
case "wiki_move":
result, err = queryWikiMoveTask(runtime, taskID)
case "wiki_move_to_drive":
result, err = queryWikiMoveToDriveTask(runtime, taskID)
case "wiki_delete_space":
result, err = queryWikiDeleteSpaceTask(runtime, taskID)
case "wiki_delete_node":
@@ -209,7 +225,7 @@ func queryExportTask(runtime *common.RuntimeContext, ticket, fileToken string) (
}, nil
}
// queryTaskCheck returns the normalized status of a folder move/delete task.
// queryTaskCheck returns the normalized status of a Drive file/folder move/delete task.
func queryTaskCheck(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
status, err := getDriveTaskCheckStatus(runtime, taskID)
if err != nil {
@@ -244,7 +260,7 @@ func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeC
switch scenario {
case "import", "export", "task_check":
required = []string{"drive:drive.metadata:readonly"}
case "wiki_move", "wiki_delete_space", "wiki_delete_node":
case "wiki_move", "wiki_move_to_drive", "wiki_delete_space", "wiki_delete_node":
required = []string{"wiki:space:read"}
}
@@ -486,6 +502,75 @@ func appendWikiMoveNodeFields(out, node map[string]interface{}) {
out["has_child"] = common.GetBool(node, "has_child")
}
// queryWikiMoveToDriveTask returns the normalized status and final Drive
// resource fields for wiki +move-to-drive. The task endpoint uses a dedicated
// result object with numeric status codes: 0 success, 1 processing, -1 failure.
func queryWikiMoveToDriveTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id").WithCause(err)
}
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": wikiMoveToDriveTaskType},
nil,
)
if err != nil {
return nil, err
}
task := common.GetMap(data, "task")
if task == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
}
result := common.GetMap(task, wikiMoveToDriveResultKey)
if result == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing %s", wikiMoveToDriveResultKey)
}
statusCode, ok := common.GetFloatOK(result, "status")
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response has missing or non-numeric %s.status", wikiMoveToDriveResultKey)
}
if statusCode != -1 && statusCode != 0 && statusCode != 1 {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"wiki task response has unsupported %s.status: %v",
wikiMoveToDriveResultKey,
statusCode,
)
}
resolvedTaskID := common.GetString(task, "task_id")
if resolvedTaskID == "" {
resolvedTaskID = taskID
}
status := int(statusCode)
statusMsg := strings.TrimSpace(common.GetString(result, "status_msg"))
if statusMsg == "" {
switch {
case status == 0:
statusMsg = "success"
case status < 0:
statusMsg = "failure"
default:
statusMsg = "processing"
}
}
return map[string]interface{}{
"scenario": "wiki_move_to_drive",
"task_id": resolvedTaskID,
"ready": status == 0,
"failed": status < 0,
"status": status,
"status_msg": statusMsg,
"obj_token": common.GetString(result, "obj_token"),
"obj_type": common.GetString(result, "obj_type"),
"url": common.GetString(result, "url"),
}, nil
}
// queryWikiDeleteSpaceTask returns the normalized status of an async wiki
// delete-space task. The backend reports a single delete_space_result object
// rather than the per-node array used by wiki move.

View File

@@ -66,6 +66,13 @@ func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) {
},
wantErr: "--task-id is required",
},
{
name: "wiki move to Drive missing task id",
flags: map[string]string{
"scenario": "wiki_move_to_drive",
},
wantErr: "--task-id is required",
},
}
for _, tt := range tests {
@@ -426,13 +433,174 @@ func TestDriveTaskResultWikiMoveIncludesFlattenedNodeFields(t *testing.T) {
}
}
func TestDriveTaskResultDryRunWikiMoveToDriveIncludesTaskTypeParam(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +task_result"}
cmd.Flags().String("scenario", "", "")
cmd.Flags().String("ticket", "", "")
cmd.Flags().String("task-id", "", "")
cmd.Flags().String("file-token", "", "")
if err := cmd.Flags().Set("scenario", "wiki_move_to_drive"); err != nil {
t.Fatalf("set --scenario: %v", err)
}
if err := cmd.Flags().Set("task-id", "raw-task-signature"); err != nil {
t.Fatalf("set --task-id: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := DriveTaskResult.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 || got.API[0].Params["task_type"] != "move_wiki_to_docs" {
t.Fatalf("wiki move-to-drive dry run = %#v", got.API)
}
}
func TestDriveTaskResultWikiMoveToDriveStatuses(t *testing.T) {
tests := []struct {
name string
status int
statusMsg string
wantReady bool
wantFailed bool
}{
{name: "success", status: 0, statusMsg: "success", wantReady: true},
{name: "processing fallback label", status: 1, wantReady: false},
{name: "failure", status: -1, statusMsg: "failure", wantFailed: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, driveTestConfig())
registry.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/raw-task-signature",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
// The external handler may omit task.task_id, so the
// result must retain the signed request ID.
"move_wiki_to_docs_result": map[string]interface{}{
"status": tt.status,
"status_msg": tt.statusMsg,
"obj_token": "docxABC",
"obj_type": "docx",
"url": "https://example.feishu.cn/docx/docxABC",
},
},
},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "wiki_move_to_drive",
"--task-id", "raw-task-signature",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunDrive() error = %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if data["scenario"] != "wiki_move_to_drive" || data["task_id"] != "raw-task-signature" {
t.Fatalf("unexpected envelope = %#v", data)
}
if data["ready"] != tt.wantReady || data["failed"] != tt.wantFailed {
t.Fatalf("readiness fields = %#v", data)
}
if tt.statusMsg == "" && data["status_msg"] != "processing" {
t.Fatalf("status_msg = %#v, want processing fallback", data["status_msg"])
}
if data["obj_token"] != "docxABC" || data["obj_type"] != "docx" || data["url"] == "" {
t.Fatalf("result fields = %#v", data)
}
})
}
}
func TestDriveTaskResultWikiMoveToDriveRejectsMissingResult(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, driveTestConfig())
registry.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/raw-task-signature",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task": map[string]interface{}{}},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "wiki_move_to_drive",
"--task-id", "raw-task-signature",
"--as", "user",
}, factory, stdout)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
}
}
func TestDriveTaskResultWikiMoveToDriveRejectsMalformedStatus(t *testing.T) {
for name, rawStatus := range map[string]interface{}{
"null": nil,
"string": "processing",
"fractional": 0.5,
"unknown value": 2,
} {
t.Run(name, func(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, driveTestConfig())
registry.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/raw-task-signature",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
"move_wiki_to_docs_result": map[string]interface{}{"status": rawStatus},
},
},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "wiki_move_to_drive",
"--task-id", "raw-task-signature",
"--as", "user",
}, factory, stdout)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
}
})
}
}
func TestValidateDriveTaskResultScopesWikiScenariosRequireWikiScope(t *testing.T) {
t.Parallel()
// wiki_move, wiki_delete_space and wiki_delete_node all read wiki task
// status, so all must require wiki:space:read. A single table keeps this
// invariant explicit without duplicating near-identical test functions.
for _, scenario := range []string{"wiki_move", "wiki_delete_space", "wiki_delete_node"} {
// Every Wiki scenario reads Wiki task status, so all must require
// wiki:space:read. A single table keeps this invariant explicit without
// duplicating near-identical test functions.
for _, scenario := range []string{"wiki_move", "wiki_move_to_drive", "wiki_delete_space", "wiki_delete_node"} {
t.Run(scenario+"/rejects missing scope", func(t *testing.T) {
t.Parallel()
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "drive:drive.metadata:readonly")

View File

@@ -0,0 +1,284 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// subOp builds a raw +batch-update sub-op for translateBatchOp tests.
func subOp(shortcut string, input map[string]interface{}) map[string]interface{} {
return map[string]interface{}{"shortcut": shortcut, "input": input}
}
// TestBatchOp_UnknownInputKeyRejected pins the key-vocabulary guard: an
// off-vocabulary sub-op input key must error with a did-you-mean instead of
// being silently ignored (silent ignore surfaced as misleading "missing
// required flag" errors — the top batch error cluster in eval traces).
func TestBatchOp_UnknownInputKeyRejected(t *testing.T) {
t.Parallel()
t.Run("invented key errors with did-you-mean", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
"sheet_name": "S1",
"rangee": "A1:B2",
"cells": []interface{}{[]interface{}{map[string]interface{}{"value": "x"}}},
}), testToken, 0)
ve := requireValidation(t, err, `unknown input key "rangee"`)
if !strings.Contains(ve.Message, `did you mean "range"`) {
t.Fatalf("message %q missing did-you-mean", ve.Message)
}
if !strings.Contains(ve.Hint, "input keys:") {
t.Fatalf("hint %q missing key contract", ve.Hint)
}
})
t.Run("system flag is not sub-op vocabulary", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
"sheet_name": "S1",
"range": "A1:B2",
"dry_run": true,
}), testToken, 0)
requireValidation(t, err, `unknown input key "dry_run"`)
})
t.Run("reserved locator in hyphen form still rejected", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
"sheet_name": "S1",
"range": "A1:B2",
"spreadsheet-token": "shtXXX",
}), testToken, 0)
requireValidation(t, err, "do not pass input.spreadsheet-token")
})
}
// TestBatchOp_HabitualKeysRewritten pins the silent rewrites: camelCase onto
// the declared flag, and the commandFlagAliases table (size → width/height on
// the resize pair — the pre-2026-07 vocabulary and the styles-protocol
// spelling, the single largest sub-op error cluster).
func TestBatchOp_HabitualKeysRewritten(t *testing.T) {
t.Parallel()
t.Run("camelCase sheetName resolves", func(t *testing.T) {
t.Parallel()
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
"sheetName": "S1",
"range": "A1:B2",
}), testToken, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
input := translated["input"].(map[string]interface{})
if input["sheet_name"] != "S1" {
t.Fatalf("sheet_name = %v, want S1", input["sheet_name"])
}
})
t.Run("size aliases to width on +cols-resize", func(t *testing.T) {
t.Parallel()
translated, err := translateBatchOp(subOp("+cols-resize", map[string]interface{}{
"sheet_name": "S1",
"range": "A:C",
"type": "pixel",
"size": float64(120),
}), testToken, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
input := translated["input"].(map[string]interface{})
width, _ := input["resize_width"].(map[string]interface{})
if width["value"] != 120 {
t.Fatalf("resize_width = %v, want value 120", input["resize_width"])
}
})
t.Run("size aliases to height on +rows-resize", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+rows-resize", map[string]interface{}{
"sheet_name": "S1",
"range": "1:3",
"type": "pixel",
"size": float64(36),
}), testToken, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("single-entry ranges unwraps onto range", func(t *testing.T) {
t.Parallel()
translated, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
"sheet_name": "S1",
"ranges": []interface{}{"A1:B2"},
}), testToken, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
input := translated["input"].(map[string]interface{})
if input["range"] != "A1:B2" {
t.Fatalf("range = %v, want A1:B2", input["range"])
}
})
t.Run("multi-entry ranges prescribes a split", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+cells-clear", map[string]interface{}{
"sheet_name": "S1",
"ranges": []interface{}{"A1:B2", "C1:D2"},
}), testToken, 0)
requireValidation(t, err, "split them into 2 sub-ops")
})
}
// TestBatchOperations_AggregatesValidationErrors pins the one-pass contract:
// several invalid ops come back in a single error (each with its own
// operations[i] context) instead of the first only — eval traces show
// fix-one-resend loops of up to 7 round trips under first-error-only.
func TestBatchOperations_AggregatesValidationErrors(t *testing.T) {
t.Parallel()
t.Run("two bad ops both reported", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOperations([]interface{}{
subOp("+cells-clear", map[string]interface{}{"range": "A1:B2"}), // missing sheet selector
subOp("+cells-set", map[string]interface{}{"sheet_name": "S1", "range": "A1"}), // missing cells
subOp("+cells-clear", map[string]interface{}{"sheet_name": "S1", "range": "A1:B2"}), // valid
}, testToken)
ve := requireValidation(t, err, "2 of 3 operations failed validation")
for _, want := range []string{"operations[0] (+cells-clear)", "operations[1] (+cells-set)", "--cells is required"} {
if !strings.Contains(ve.Message, want) {
t.Fatalf("message %q missing %q", ve.Message, want)
}
}
})
t.Run("single bad op keeps the standalone-shaped error", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOperations([]interface{}{
subOp("+cells-set", map[string]interface{}{"sheet_name": "S1", "range": "A1"}),
}, testToken)
ve := requireValidation(t, err, "--cells is required")
if strings.Contains(ve.Message, "failed validation") {
t.Fatalf("single-error message must not use the aggregate wrapper: %q", ve.Message)
}
})
}
// TestCellsSetInput_MatrixPrecheck pins the local cells-vs-range guard that
// front-runs the server's mid-batch "does not match range" failures.
func TestCellsSetInput_MatrixPrecheck(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input map[string]interface{}
wantContains string // "" = expect success
}{
{
"empty cells prescribes +cells-clear",
map[string]interface{}{"sheet_name": "S1", "range": "A1:B2", "cells": []interface{}{}},
"+cells-clear",
},
{
"row count mismatch",
map[string]interface{}{"sheet_name": "S1", "range": "A1:B3",
"cells": []interface{}{
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
}},
"has 1 rows but --range \"A1:B3\" spans 3 rows",
},
{
"column count mismatch",
map[string]interface{}{"sheet_name": "S1", "range": "A1:B1",
"cells": []interface{}{
[]interface{}{map[string]interface{}{"value": "a"}},
}},
"has 1 columns but --range \"A1:B1\" spans 2 columns",
},
{
"matching matrix passes",
map[string]interface{}{"sheet_name": "S1", "range": "A1:B2",
"cells": []interface{}{
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
[]interface{}{map[string]interface{}{"value": "c"}, map[string]interface{}{"value": "d"}},
}},
"",
},
{
"bare single-cell range enforces the 1x1 match (07-21: server rejects anchors too)",
map[string]interface{}{"sheet_name": "S1", "range": "A1",
"cells": []interface{}{
[]interface{}{map[string]interface{}{"value": "a"}, map[string]interface{}{"value": "b"}},
}},
"has 2 columns but --range \"A1\" spans 1 columns",
},
{
"single-cell range with a single cell passes",
map[string]interface{}{"sheet_name": "S1", "range": "B3",
"cells": []interface{}{
[]interface{}{map[string]interface{}{"value": "a"}},
}},
"",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+cells-set", tc.input), testToken, 0)
if tc.wantContains == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
requireValidation(t, err, tc.wantContains)
})
}
}
// TestFlattenToolErrorMsg_PartialFailureRecovery pins the no-rollback recovery
// prescription appended to server-side "N succeeded, M failed" errors.
func TestFlattenToolErrorMsg_PartialFailureRecovery(t *testing.T) {
t.Parallel()
wrap := func(inner string) string {
return `{"error":` + jsonQuote(inner) + `}`
}
t.Run("single failure prescribes resend-from-index", func(t *testing.T) {
t.Parallel()
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 4 succeeded, 1 failed","failures":[{"index":4,"tool_name":"set_cell_range","error":"cells is required"}]}`))
for _, want := range []string{"operations[4] (set_cell_range)", "no rollback", "resend only operations[4:]"} {
if !strings.Contains(msg, want) {
t.Fatalf("msg %q missing %q", msg, want)
}
}
})
t.Run("multiple failures prescribe failed-only resend", func(t *testing.T) {
t.Parallel()
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 3 succeeded, 2 failed","failures":[{"index":1,"tool_name":"set_cell_range","error":"e1"},{"index":3,"tool_name":"resize_range","error":"e2"}]}`))
if !strings.Contains(msg, "resend only the failed operations") {
t.Fatalf("msg %q missing failed-only prescription", msg)
}
})
t.Run("zero succeeded gets no note", func(t *testing.T) {
t.Parallel()
msg := flattenToolErrorMsg(wrap(`{"message":"batch_update: 0 succeeded, 1 failed","failures":[{"index":0,"tool_name":"set_cell_range","error":"e"}]}`))
if strings.Contains(msg, "no rollback") {
t.Fatalf("msg %q must not carry the note when nothing was applied", msg)
}
})
}
// jsonQuote wraps s as a JSON string literal (escaping quotes), mirroring how
// the server double-encodes the inner error payload.
func jsonQuote(s string) string {
return `"` + strings.ReplaceAll(strings.ReplaceAll(s, `\`, `\\`), `"`, `\"`) + `"`
}

View File

@@ -763,7 +763,7 @@ func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
{
"+pivot-create summarize_by out of enum",
"+pivot-create",
`{"sheet-id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
`{"target_sheet_id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
"summarize_by",
},
// +chart-create properties.position.row has minimum:0 — P0

View File

@@ -4,8 +4,11 @@
package sheets
import (
"fmt"
"sort"
"strings"
"github.com/larksuite/cli/internal/suggest"
)
// ─── +batch-update sub-op dispatch ─────────────────────────────────────
@@ -84,7 +87,14 @@ func objDeleteTranslate(spec objectCRUDSpec) batchTranslateFn {
// flag error is identical too (locked by TestBatchOp_ErrorEquivalence).
var batchOpDispatch = map[string]batchOpMapping{
// ─── 单元格内容 ──────────────────────────────────────────────────
"+cells-set": {"set_cell_range", cellsSetInput},
"+cells-set": {"set_cell_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
// The --writes plural form expands into its own atomic batch and
// cannot nest; sub-ops carry one range+cells each.
if fv.Changed("writes") {
return nil, sheetsValidationForFlag("writes", `"writes" is not supported inside +batch-update (it expands into its own atomic batch); call +cells-set --writes standalone, or give each sub-op a single range + cells`)
}
return cellsSetInput(fv, token, sid, sname)
}},
"+cells-set-style": {"set_cell_range", cellsSetStyleInput},
"+cells-clear": {"clear_cell_range", cellsClearInput},
"+cells-replace": {"replace_data", replaceInput},
@@ -102,6 +112,11 @@ var batchOpDispatch = map[string]batchOpMapping{
// ─── 行列结构 (modify_sheet_structure, operation 区分) ──────────
"+dim-insert": {"modify_sheet_structure", dimInsertInput},
"+dim-delete": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
// The --ranges plural form expands into its own atomic batch and
// cannot nest; sub-ops carry one range each.
if fv.Changed("ranges") {
return nil, sheetsValidationForFlag("ranges", `"ranges" is not supported inside +batch-update (it expands into its own atomic batch); call +dim-delete --ranges standalone, or give each sub-op a single "range"`)
}
return dimRangeOpInput(fv, token, sid, sname, "delete")
}},
"+dim-hide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
@@ -301,6 +316,133 @@ func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[str
// +batch-update 顶层 --url/--token 统一提供excel_id / spreadsheet_token / url
var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
// wrappedSubOpInputKeys are nested MCP-body container keys that must never
// appear at a sub-op input's top level — their presence means the caller
// pasted a shortcut's structured *output* (e.g. a {"cell_styles":{…}} block)
// where the flattened flag keys belong. None of the batch sub-op translators
// read input under these names, so rejecting them is safe.
var wrappedSubOpInputKeys = []string{"cell_styles", "cell_merges", "styles"}
// subOpKeyVocabulary returns the set of hyphen-canonical flag names a sub-op
// input may carry for `sc`: every non-system flag in flag-defs except the
// spreadsheet locators (reserved for the batch top level). Nil when the
// shortcut has no flag-defs entry (vocabulary checks are then skipped).
func subOpKeyVocabulary(sc string) map[string]bool {
defs, _ := loadFlagDefs()
spec, ok := defs[sc]
if !ok {
return nil
}
vocab := make(map[string]bool, len(spec.Flags))
for _, df := range spec.Flags {
if df.Kind == "system" || df.Name == "url" || df.Name == "spreadsheet-token" {
continue
}
vocab[df.Name] = true
}
return vocab
}
// camelToKebab converts a lowerCamelCase key to its kebab form
// (sheetName → sheet-name). Returns "" when the key carries no uppercase
// letter (nothing to convert).
func camelToKebab(key string) string {
if strings.ToLower(key) == key {
return ""
}
var b strings.Builder
for i, r := range key {
if r >= 'A' && r <= 'Z' {
if i > 0 {
b.WriteByte('-')
}
b.WriteRune(r + ('a' - 'A'))
continue
}
b.WriteRune(r)
}
return b.String()
}
// normalizeSubOpInputKeys validates every sub-op input key against the
// shortcut's flag vocabulary, rewriting habitual spellings in place and
// rejecting anything that matches nothing. Eval traces show unknown keys were
// previously ignored silently, which turned "wrong key" (size for width,
// camelCase sheetName, an invented styles object) into misleading
// "missing required flag" errors downstream — the single largest batch error
// cluster. Rewrites applied, in order:
//
// - underscore ↔ hyphen forms of a declared flag (already tolerated by
// mapFlagView — accepted here as-is)
// - lowerCamelCase → the declared flag (sheetName → sheet_name)
// - the command's intuitive-alias table (size → width/height on the resize
// pair) — the same commandFlagAliases the cobra path applies
// - "ranges" with a single-entry array unwraps onto "range"; a multi-entry
// array gets a split-into-sub-ops prescription instead
//
// Anything else errors with a did-you-mean. Returns a bare error; the caller
// wraps it with the operations[i] (<shortcut>) context and key contract.
func normalizeSubOpInputKeys(sc string, input map[string]interface{}) error {
vocab := subOpKeyVocabulary(sc)
if vocab == nil {
return nil
}
keys := make([]string, 0, len(input))
for k := range input {
keys = append(keys, k)
}
sort.Strings(keys)
aliases := commandFlagAliases[sc]
for _, k := range keys {
hv := strings.ReplaceAll(k, "_", "-")
if vocab[hv] {
continue
}
if kebab := camelToKebab(k); kebab != "" && vocab[kebab] {
input[strings.ReplaceAll(kebab, "-", "_")] = input[k]
delete(input, k)
continue
}
if target, ok := aliases[strings.ToLower(hv)]; ok && vocab[target] {
if _, taken := input[target]; !taken {
if _, taken := input[strings.ReplaceAll(target, "-", "_")]; !taken {
input[target] = input[k]
delete(input, k)
continue
}
}
}
if strings.ToLower(hv) == "ranges" && vocab["range"] && !vocab["ranges"] {
if arr, isArr := input[k].([]interface{}); isArr {
if len(arr) == 1 {
if s, isStr := arr[0].(string); isStr {
input["range"] = s
delete(input, k)
continue
}
}
return fmt.Errorf("%s takes a single \"range\" per sub-op, got %d entries in %q — split them into %d sub-ops (one per range)", sc, len(arr), k, len(arr)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
}
if s, isStr := input[k].(string); isStr {
input["range"] = s
delete(input, k)
continue
}
}
msg := fmt.Sprintf("unknown input key %q", k)
display := make([]string, 0, len(vocab))
for name := range vocab {
display = append(display, strings.ReplaceAll(name, "-", "_"))
}
sort.Strings(display)
if match := suggest.Closest(strings.ToLower(hv), display, 1); len(match) > 0 {
msg += fmt.Sprintf(" — did you mean %q?", match[0])
}
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error
}
return nil
}
// translateBatchOp 把一个 CLI 视角的 {shortcut, input} 翻成底层 MCP
// batch_update 的 {tool_name, input}。`index` 用于错误信息定位。input 用
// shortcut 的 CLI flag 名(连字符/下划线均可),经该 shortcut 的 standalone
@@ -312,6 +454,7 @@ var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
// - input 不是 object
// - input 里手填了 operation由 shortcut 名隐含,禁手填以防 mismatch
// - input 里手填了 excel_id / spreadsheet_token / url
// - input 顶层出现 cell_styles / cell_merges / styles误贴 MCP body 包裹结构)
// - 子操作的 translator 报错(如缺必填字段)
func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) {
op, ok := raw.(map[string]interface{})
@@ -335,7 +478,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
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)",
"(read ops / fan-out wrappers like +batch-update / +styles-put / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)",
index, sc,
).WithHint("allowed shortcuts: %s", strings.Join(allowedBatchShortcuts(), ", "))
}
@@ -358,11 +501,30 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
)
}
// 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。
for _, k := range reservedSubOpKeys {
// 连字符 / 下划线两种写法都算命中spreadsheet-token 与 spreadsheet_token 同罪)。
for userKey := range input {
normalized := strings.ReplaceAll(userKey, "-", "_")
for _, k := range reservedSubOpKeys {
if normalized == k {
return nil, sheetsValidationForFlag(
"operations",
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
index, sc, userKey,
)
}
}
}
// Reject a "wrapped structure" sub-op input: agents copy a shortcut's nested
// output container (e.g. +workbook-create --styles' {"cell_styles":{…}}) into
// the op input, but the op input is the shortcut's own flags flattened into
// JSON keys, not that wrapper. Left unflagged this surfaces far downstream as
// an unrelated "at least one style flag is required" (helpers.go), which never
// points at the real mistake.
for _, k := range wrappedSubOpInputKeys {
if _, has := input[k]; has {
return nil, sheetsValidationForFlag(
"operations",
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
`operations[%d] (%s): op input is the shortcut's flags flattened as JSON keys (e.g. "background_color": "#EBF1F8"); do not wrap in %s`,
index, sc, k,
)
}
@@ -373,6 +535,16 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
}
}
// Reject / rewrite off-vocabulary input keys BEFORE any value reads: an
// unknown key silently ignored surfaces later as a misleading
// "missing required flag" error (the top batch error cluster in evals).
if err := normalizeSubOpInputKeys(sc, input); err != nil {
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
}
fv := newMapFlagViewForCommand(sc, input)
// operations is skipped by parse-time schema validation, so type-check the
// sub-op's scalar fields here before the translator reads them via
@@ -410,7 +582,14 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
// matrix, on the operations axis.
const maxBatchOperations = 100
// translateBatchOperations 翻译整个 ops 数组fail-fast遇错立即返回。
// batchOpErrorDisplayLimit bounds how many per-op validation failures ride
// on one aggregated --operations error, mirroring the schema validator's
// display cap.
const batchOpErrorDisplayLimit = 5
// translateBatchOperations 翻译整个 ops 数组。逐 op 校验并**收集全部失败**
// 一次性返回(不再 fail-fast——agent 一轮就能修完所有坏 op而不是
// 修一个、重试、再撞下一个。cell 安全上限仍是全局判定,命中即返回。
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
if len(rawOps) == 0 {
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
@@ -422,10 +601,15 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}
}
out := make([]interface{}, 0, len(rawOps))
var totalCells int64
var opErrs []error
for i, raw := range rawOps {
translated, err := translateBatchOp(raw, token, i)
if err != nil {
return nil, err
opErrs = append(opErrs, err)
continue
}
if len(opErrs) > 0 {
continue // already failing — keep scanning for more bad ops, skip cell math.
}
totalCells += translatedCellCount(translated)
if totalCells > maxStampMatrixCells {
@@ -435,7 +619,27 @@ func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}
}
out = append(out, translated)
}
return out, nil
switch len(opErrs) {
case 0:
return out, nil
case 1:
return nil, opErrs[0] // single failure keeps the historical error byte-for-byte.
}
shown := opErrs
truncated := false
if len(shown) > batchOpErrorDisplayLimit {
shown = shown[:batchOpErrorDisplayLimit]
truncated = true
}
parts := make([]string, 0, len(shown))
for i, e := range shown {
parts = append(parts, fmt.Sprintf("%d) %s", i+1, e.Error()))
}
msg := fmt.Sprintf("%d of %d operations failed validation: %s", len(opErrs), len(rawOps), strings.Join(parts, "; "))
if truncated {
msg += fmt.Sprintf("; (%d more not shown — fix these first)", len(opErrs)-batchOpErrorDisplayLimit)
}
return nil, sheetsValidationForFlag("operations", "%s", msg).WithCause(opErrs[0])
}
func translatedCellCount(op map[string]interface{}) int64 {

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// TestCellsSetWrites pins the --writes plural form: scattered (cross-sheet)
// regions fan into ONE atomic batch_update, each item self-carrying its
// sheet selector (no top-level fallback — same convention as +batch-update
// sub-ops and +styles-put items), with per-item errors aggregated.
func TestCellsSetWrites(t *testing.T) {
t.Parallel()
writes := func(items string, extra ...string) (string, string, error) {
args := append([]string{
"--url", testURL, "--dry-run", "--writes", items,
}, extra...)
return runShortcutCapturingErr(t, CellsSet, args)
}
t.Run("cross-sheet items expand into one batch", func(t *testing.T) {
t.Parallel()
stdout, _, err := writes(`[
{"sheet_name":"明细","range":"D5","cells":[[{"formula":"=IFERROR(C5/B5,0)"}]]},
{"sheet_name":"汇总","range":"B3","cells":[[{"formula":"=SUM(C:C)"}]]}
]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, want := range []string{"batch_update", "明细", "汇总", "IFERROR"} {
if !strings.Contains(stdout, want) {
t.Fatalf("dry-run body missing %q: %s", want, stdout[:min(len(stdout), 400)])
}
}
})
t.Run("item without sheet selector errors", func(t *testing.T) {
t.Parallel()
_, _, err := writes(`[{"range":"A1","cells":[[{"value":"x"}]]}]`)
requireValidation(t, err, "sheet-id or --sheet-name")
})
t.Run("top-level sheet selector rejected with prescription", func(t *testing.T) {
t.Parallel()
_, _, err := writes(`[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
"--sheet-name", "S1")
requireValidation(t, err, "put sheet_name (or sheet_id) inside each writes item")
})
t.Run("writes and range are mutually exclusive", func(t *testing.T) {
t.Parallel()
_, _, err := writes(`[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
"--range", "A1")
requireValidation(t, err, "mutually exclusive")
})
t.Run("per-item errors aggregate", func(t *testing.T) {
t.Parallel()
// Both items pass the --writes schema (range+cells present) but fail
// deeper: item 0 a matrix mismatch, item 1 a missing sheet selector.
_, _, err := writes(`[
{"sheet_name":"S1","range":"A1:B2","cells":[[{"value":"x"}]]},
{"range":"C1","cells":[[{"value":"y"}]]}
]`)
ve := requireValidation(t, err, "--writes has 2 issues")
for _, want := range []string{"--writes[0]", "--writes[1]", "sheet-name"} {
if !strings.Contains(ve.Message, want) {
t.Fatalf("message %q missing %q", ve.Message, want)
}
}
})
t.Run("item keys go through the vocabulary layer", func(t *testing.T) {
t.Parallel()
stdout, _, err := writes(`[{"sheetName":"S1","range":"A1","cells":[[{"value":"x"}]]}]`)
if err != nil {
t.Fatalf("camelCase sheetName must normalize: %v", err)
}
if !strings.Contains(stdout, "S1") {
t.Fatalf("normalized item missing sheet: %s", stdout[:min(len(stdout), 300)])
}
})
t.Run("cannot nest inside batch-update", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
"writes": []interface{}{map[string]interface{}{
"sheet_name": "S1", "range": "A1", "cells": []interface{}{[]interface{}{map[string]interface{}{"value": "x"}}},
}},
}), testToken, 0)
requireValidation(t, err, "not supported inside +batch-update")
})
t.Run("styles flag gets the layering prescription", func(t *testing.T) {
t.Parallel()
// Ergonomics (FlagErrorFunc hints) mount via the registry, not the
// bare shortcut var — mirror the real CLI wiring.
sc := shortcutFromRegistry(t, "+cells-set")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL, "--dry-run",
"--writes", `[{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}]`,
"--styles", `{"styles":[]}`,
})
ve := requireValidation(t, err, "unknown flag")
if !strings.Contains(ve.Hint, "+styles-put") || !strings.Contains(ve.Hint, "cell_styles") {
t.Fatalf("want the styles-put layering hint, got hint=%q", ve.Hint)
}
})
}

View File

@@ -0,0 +1,150 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"fmt"
"sort"
"strings"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// ─── +chart-create --print-example ─────────────────────────────────────
//
// chart-create's --properties schema is ~1,750 pretty-printed lines; eval
// traces show agents paging through the full --print-schema dump for every
// chart (25 round trips in one 35-task batch) and still missing deep
// required fields. A ready-to-edit minimal template per chart type answers
// the actual question ("what does a valid payload look like") in one local
// call. Wired through PostMount, same pattern as +csv-put's flag-group
// tweaks — no framework change.
//
// Templates mirror the canonical examples in the lark-sheets-chart
// reference (sheet-skill-spec canonical-spec/references/lark_sheet_chart):
// inline headerMode with refs covering the header row, 1-based indices,
// quoted sheet prefix in refs.
var chartExampleTemplates = map[string]string{
"column": chartSimpleExample("column"),
"bar": chartSimpleExample("bar"),
"line": chartSimpleExample("line"),
"area": chartSimpleExample("area"),
"radar": chartSimpleExample("radar"),
"scatter": `{
"position": {"row": 1, "col": "F"},
"size": {"width": 600, "height": 400},
"snapshot": {
"title": {"text": "图表标题"},
"plotArea": {"plot": {"type": "scatter"}},
"data": {
"refs": [{"value": "'Sheet1'!A1:B20"}],
"dim1": {"serie": {"index": 1}},
"dim2": {"series": [{"index": 2}]}
}
}
}`,
"pie": `{
"position": {"row": 1, "col": "F"},
"size": {"width": 600, "height": 450},
"snapshot": {
"title": {"text": "占比标题"},
"plotArea": {"plot": {
"type": "pie",
"series": [{
"index": 1,
"sectors": {"sector": [{"index": 1, "offsetRadius": 0.05}]}
}]
}},
"data": {
"refs": [{"value": "'Sheet1'!A1:B11"}],
"dim1": {"serie": {"index": 1, "aggregate": true}},
"dim2": {"series": [{"index": 2, "aggregateType": "sum"}]}
}
}
}`,
"combo": `{
"position": {"row": 1, "col": "F"},
"size": {"width": 700, "height": 400},
"snapshot": {
"title": {"text": "柱线组合"},
"plotArea": {"plot": {
"type": "combo",
"series": [
{"index": 2, "comboType": "column"},
{"index": 3, "comboType": "line"}
]
}},
"data": {
"refs": [{"value": "'Sheet1'!A1:C13"}],
"dim1": {"serie": {"index": 1}},
"dim2": {"series": [{"index": 2}, {"index": 3}]}
}
}
}`,
}
// chartSimpleExample renders the shared minimal shape for plot types that
// need nothing beyond plot.type (column / bar / line / area / radar).
func chartSimpleExample(typ string) string {
return fmt.Sprintf(`{
"position": {"row": 1, "col": "F"},
"size": {"width": 600, "height": 400},
"snapshot": {
"title": {"text": "图表标题"},
"plotArea": {"plot": {"type": %q}},
"data": {
"refs": [{"value": "'Sheet1'!A1:C10"}],
"dim1": {"serie": {"index": 1}},
"dim2": {"series": [{"index": 2}, {"index": 3}]}
}
}
}`, typ)
}
func chartExampleTypes() []string {
types := make([]string, 0, len(chartExampleTemplates))
for t := range chartExampleTemplates {
types = append(types, t)
}
sort.Strings(types)
return types
}
// withChartPrintExample wraps +chart-create's PostMount so the command grows
// a --print-example flag that short-circuits execution and prints a minimal
// ready-to-edit --properties template — purely local, no identity or
// network. --properties' cobra-level required annotation is relaxed (the
// input builder still enforces it on the real path, same trick as
// +csv-put's --csv).
func withChartPrintExample(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
return func(cmd *cobra.Command) {
if prev != nil {
prev(cmd)
}
cmd.Flags().String("print-example", "",
"Print a minimal ready-to-edit --properties template for a chart type ("+strings.Join(chartExampleTypes(), "|")+") and exit")
// Only --properties carries a cobra-level required annotation (the
// locator flags are xor pairs, enforced later); the input builder
// still errors "--properties is required" on the real path.
if fl := cmd.Flags().Lookup("properties"); fl != nil {
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
}
prevRunE := cmd.RunE
cmd.RunE = func(c *cobra.Command, args []string) error {
typ, _ := c.Flags().GetString("print-example")
if typ == "" {
return prevRunE(c, args)
}
tmpl, ok := chartExampleTemplates[typ]
if !ok {
return common.ValidationErrorf("no example for chart type %q; available: %s",
typ, strings.Join(chartExampleTypes(), ", ")).WithParam("--print-example")
}
fmt.Fprintln(c.OutOrStdout(), tmpl)
return nil
}
}
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
// TestChartPrintExample pins the --print-example contract: a known type
// prints its template and skips execution entirely; an unknown type lists
// the available ones.
func TestChartPrintExample(t *testing.T) {
t.Parallel()
t.Run("prints template without locator flags", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+chart-create")
parent, _, _, _ := newTestRig(t, sc)
var buf bytes.Buffer
parent.SetOut(&buf) // --print-example writes via cobra's OutOrStdout
parent.SetArgs([]string{sc.Command, "--print-example", "pie"})
if err := parent.Execute(); err != nil {
t.Fatalf("print-example should run standalone, got: %v", err)
}
if !strings.Contains(buf.String(), `"sectors"`) {
t.Errorf("pie template should carry sectors, got %q", buf.String())
}
})
t.Run("unknown type lists available", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+chart-create")
_, _, err := runShortcutCapturingErr(t, sc, []string{"--print-example", "donut"})
ve := requireValidation(t, err, `no example for chart type "donut"`)
if !strings.Contains(ve.Message, "pie") {
t.Errorf("message should list available types, got %q", ve.Message)
}
})
}
// TestChartExampleTemplates_ValidateAgainstSchema drift-guards every
// template against the embedded chart-create properties schema — a template
// the CLI itself would reject is worse than none.
func TestChartExampleTemplates_ValidateAgainstSchema(t *testing.T) {
t.Parallel()
for typ, tmpl := range chartExampleTemplates {
t.Run(typ, func(t *testing.T) {
t.Parallel()
var v interface{}
if err := json.Unmarshal([]byte(tmpl), &v); err != nil {
t.Fatalf("template is not valid JSON: %v", err)
}
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{"properties": v})
if err := validateValueAgainstSchema(fv, "properties", v); err != nil {
t.Errorf("template rejected by embedded schema: %v", err)
}
})
}
}

View File

@@ -821,12 +821,10 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)",
"default": "none",
"desc": "Style inheritance for the new row/column: `before` (from the preceding row/column) / `after` (from the following row/column). Omit the flag to inherit the following row/column (same as `after`) — the backend cannot leave a new row/column unstyled; for a truly blank row/column, clear formats afterwards with +cells-clear --scope formats. Insertion always lands before `--position`; this only selects which side's style is copied.",
"enum": [
"before",
"after",
"none"
"after"
]
},
{
@@ -887,8 +885,19 @@
"name": "range",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"
"required": "xor",
"desc": "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`. XOR with `--ranges`"
},
{
"name": "ranges",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Multiple row/column ranges to delete as a JSON array (up to 100 items, e.g. `[\"5:5\",\"8:8\",\"11:13\"]` or `[\"C:C\",\"F:G\"]`); rows and columns cannot be mixed, ranges must not overlap; XOR with `--range`. CLI sorts positions in DESCENDING order into one atomic batch delete — ascending deletion would shift later indexes as earlier rows/columns disappear; the CLI handles the ordering",
"input": [
"file",
"stdin"
]
},
{
"name": "yes",
@@ -1277,13 +1286,14 @@
"kind": "own",
"type": "string_slice",
"required": "optional",
"desc": "Comma-separated info categories to include",
"desc": "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)",
"enum": [
"value",
"formula",
"style",
"comment",
"data_validation"
"data_validation",
"truncation"
]
},
{
@@ -1291,15 +1301,29 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
"desc": "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.",
"default": "500000"
},
{
"name": "output-path",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
},
{
"name": "skip-hidden",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Skip hidden rows and columns; default `false`"
"desc": "Skip hidden or collapsed rows and columns. Default `false`; when `--skip-filter` is omitted, filtered-out rows follow this value for backward compatibility"
},
{
"name": "skip-filter",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Skip filtered-out rows. When omitted, inherits `--skip-hidden`; explicitly set to `false` to keep filtered-out rows while skipping hidden rows and columns"
},
{
"name": "dry-run",
@@ -1392,17 +1416,24 @@
"name": "range",
"kind": "own",
"type": "string",
"required": "required",
"desc": "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"
"required": "optional",
"desc": "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet). Optional: when omitted the whole sheet is read (clipped to the actual grid bounds; actual_range in the response names what was read); pair with --max-chars / --output-path on large sheets"
},
{
"name": "max-chars",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
"desc": "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.",
"default": "500000"
},
{
"name": "output-path",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
},
{
"name": "include-row-prefix",
"kind": "own",
@@ -1465,6 +1496,21 @@
"required": "optional",
"desc": "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"
},
{
"name": "max-chars",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max output chars per call; default 500000 (safety cap). The underlying tool truncates at ~50000 even when unset, so this is sent explicitly to raise it; for a full untruncated read use --output-path (auto-unlimited).",
"default": "500000"
},
{
"name": "output-path",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
},
{
"name": "no-header",
"kind": "own",
@@ -1691,33 +1737,44 @@
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
"desc": "Sheet reference_id (XOR with `--sheet-name`); not accepted with `--writes` (each writes item carries its own sheet selector)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
"desc": "Sheet name (XOR with `--sheet-id`); not accepted with `--writes` (each writes item carries its own sheet selector)"
},
{
"name": "range",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Write range (A1 notation)"
"required": "xor",
"desc": "Write range (A1 notation). XOR with `--writes` (single region: --range+--cells; multiple regions: --writes)"
},
{
"name": "cells",
"kind": "own",
"type": "string",
"required": "required",
"required": "xor",
"desc": "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields",
"input": [
"file",
"stdin"
]
},
{
"name": "writes",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Multi-region write as a JSON array (up to 100 items), each `{sheet_name|sheet_id, range, cells}` — the sheet selector LIVES IN EACH ITEM (same convention as +batch-update sub-ops and +styles-put items; the top-level --sheet-name is rejected). cells has the same shape as `--cells` (2D array; per-cell cell_styles/border_styles allowed). The whole array goes out as ONE atomic batched request, cross-sheet supported; typical use: fixing formulas scattered across ranges/sheets — do not assemble a +batch-update operations array for this. XOR with `--range`+`--cells`; range-level uniform styling stays with +styles-put afterwards",
"input": [
"file",
"stdin"
]
},
{
"name": "allow-overwrite",
"kind": "own",
@@ -2787,6 +2844,43 @@
}
]
},
"+styles-put": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator (target sheets are named inside --styles items)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "styles",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Visual spec JSON applied to an EXISTING spreadsheet: top-level `{styles:[...]}`, one item per target sheet (`name` is the real sheet name), each giving at least one of `cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze`. The vocabulary is identical to `--styles` on `+workbook-create` / `+table-put` (cell_styles = A1 range + flat style fields, borders via the `border` shorthand {style,weight,color} applied to all four sides — border_styles only for per-side differences; row/col sizes = row/column range + size in px — type only for standard/auto; merges = cell range; freeze = `{rows:N, cols:N}`). The whole spec expands into one atomic batched request; ranges may target any region of the sheet",
"input": [
"file",
"stdin"
]
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the batched request template for each expanded operation; no network side effects"
}
]
},
"+batch-update": {
"risk": "high-risk-write",
"flags": [
@@ -2809,7 +2903,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.",
"desc": "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Fail-fast by default: the first failure aborts the remaining operations and already-applied sub-operations are NOT rolled back (on \"N succeeded, M failed\" resend only the failed tail, not the whole batch); pass --continue-on-error to keep going past failures; no nesting; executed serially.",
"input": [
"file",
"stdin"

View File

@@ -648,6 +648,35 @@
}
}
}
},
"writes": {
"type": "array",
"description": "多区域写入项数组(最多 100 项),整批单次原子提交;支持跨 sheet。",
"items": {
"type": "object",
"required": [
"range",
"cells"
],
"properties": {
"sheet_id": {
"type": "string",
"description": "目标子表 reference_id与 sheet_name 二选一,必须写在每一项里(不认顶层 sheet 定位)。"
},
"sheet_name": {
"type": "string",
"description": "目标子表名;与 sheet_id 二选一,必须写在每一项里。"
},
"range": {
"type": "string",
"description": "A1 矩形范围,行列维度必须与 cells 严格一致(同 --range。"
},
"cells": {
"type": "array",
"description": "二维单元格数组,结构同 --cellsvalue / formula / cell_styles / border_styles 等,见 set_cell_range#/properties/cells。"
}
}
}
}
},
"+cells-set-style": {
@@ -7748,87 +7777,7 @@
}
}
},
"+table-put": {
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
"items": {
"type": "object",
"required": [
"name",
"columns",
"data"
],
"properties": {
"name": {
"type": "string",
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
},
"start_cell": {
"type": "string",
"default": "A1",
"description": "写入起点单元格A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
},
"mode": {
"type": "string",
"enum": [
"overwrite",
"append"
],
"default": "overwrite",
"description": "overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头。"
},
"header": {
"type": "boolean",
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false避免在已有表头下重复显式给值可覆盖。"
},
"allow_overwrite": {
"type": "boolean",
"default": true,
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success。默认 true。"
},
"columns": {
"type": "array",
"minItems": 1,
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
"items": {
"type": "string"
}
},
"data": {
"type": "array",
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本null 表示空单元格。",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean",
"null"
],
"description": "单元格值date→ISO yyyy-mm-dd 字符串number→数值json.Number 精度保留bool→布尔string→文本null→空单元格。"
}
}
},
"dtypes": {
"type": "object",
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 objectstring + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number精度保留`bool` / `boolean` → bool`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`数字样字符串如「00123」不会塌缩成数字。",
"additionalProperties": {
"type": "string"
}
},
"formats": {
"type": "object",
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等。percent 列的数值尺度由调用方负责0.0469 配 `0.00%` 显示 4.69%)。",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"+styles-put": {
"styles": {
"items": {
"properties": {
@@ -7856,12 +7805,16 @@
"type": "array"
},
"cell_styles": {
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
"items": {
"properties": {
"background_color": {
"type": "string"
},
"border": {
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
"type": "object"
},
"border_styles": {
"type": "object",
"description": "边框配置,结构同 +cells-set-style --border-styles。",
@@ -8055,7 +8008,7 @@
"type": "array"
},
"col_sizes": {
"description": "列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size。",
"description": "列宽操作数组range 使用列范围如 A:C给 sizepx即像素列宽type 可省略);type 为 standard 时不带 size。",
"items": {
"properties": {
"range": {
@@ -8073,19 +8026,32 @@
}
},
"required": [
"range",
"type"
"range"
],
"type": "object"
},
"type": "array"
},
"freeze": {
"description": "冻结行列rows = 冻结前 N 行cols = 冻结前 N 列0 或省略 = 该维度不冻结)。",
"properties": {
"cols": {
"minimum": 0,
"type": "integer"
},
"rows": {
"minimum": 0,
"type": "integer"
}
},
"type": "object"
},
"name": {
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1其 name 会被忽略)。",
"type": "string"
},
"row_sizes": {
"description": "行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size。",
"description": "行高操作数组range 使用行范围如 1:3给 sizepx即像素行高type 可省略);type 为 standard/auto 时不带 size。",
"items": {
"properties": {
"range": {
@@ -8104,8 +8070,395 @@
}
},
"required": [
"range",
"type"
"range"
],
"type": "object"
},
"type": "array"
}
},
"required": [
"name"
],
"type": "object"
},
"type": "array"
}
},
"+table-put": {
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
"items": {
"type": "object",
"required": [
"name",
"columns",
"data"
],
"properties": {
"name": {
"type": "string",
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
},
"start_cell": {
"type": "string",
"default": "A1",
"description": "写入起点单元格A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
},
"mode": {
"type": "string",
"enum": [
"overwrite",
"append"
],
"default": "overwrite",
"description": "overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头。"
},
"header": {
"type": "boolean",
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false避免在已有表头下重复显式给值可覆盖。"
},
"allow_overwrite": {
"type": "boolean",
"default": true,
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success。默认 true。"
},
"columns": {
"type": "array",
"minItems": 1,
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
"items": {
"type": "string"
}
},
"data": {
"type": "array",
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本null 表示空单元格。",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean",
"null"
],
"description": "单元格值date→ISO yyyy-mm-dd 字符串number→数值json.Number 精度保留bool→布尔string→文本null→空单元格。"
}
}
},
"dtypes": {
"type": "object",
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 objectstring + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number精度保留`bool` / `boolean` → bool`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`数字样字符串如「00123」不会塌缩成数字。",
"additionalProperties": {
"type": "string"
}
},
"formats": {
"type": "object",
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等。percent 列的数值尺度由调用方负责0.0469 配 `0.00%` 显示 4.69%)。",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"styles": {
"items": {
"properties": {
"cell_merges": {
"description": "单元格合并操作数组range 使用 A1 单元格范围merge_type 默认 all。",
"items": {
"properties": {
"merge_type": {
"enum": [
"all",
"rows",
"columns"
],
"type": "string"
},
"range": {
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"cell_styles": {
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
"items": {
"properties": {
"background_color": {
"type": "string"
},
"border": {
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
"type": "object"
},
"border_styles": {
"type": "object",
"description": "边框配置,结构同 +cells-set-style --border-styles。",
"properties": {
"bottom": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"left": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"right": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"top": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
}
}
},
"font_color": {
"type": "string"
},
"font_family": {
"type": "string"
},
"font_line": {
"enum": [
"none",
"underline",
"line-through"
],
"type": "string"
},
"font_size": {
"type": "number"
},
"font_style": {
"enum": [
"normal",
"italic"
],
"type": "string"
},
"font_weight": {
"enum": [
"normal",
"bold"
],
"type": "string"
},
"horizontal_alignment": {
"enum": [
"left",
"center",
"right"
],
"type": "string"
},
"number_format": {
"type": "string"
},
"range": {
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
"type": "string"
},
"vertical_alignment": {
"enum": [
"top",
"middle",
"bottom"
],
"type": "string"
},
"word_wrap": {
"enum": [
"overflow",
"auto-wrap",
"word-clip"
],
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"col_sizes": {
"description": "列宽操作数组range 使用列范围如 A:C给 sizepx即像素列宽type 可省略type 为 standard 时不带 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard"
],
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"freeze": {
"description": "冻结行列rows = 冻结前 N 行cols = 冻结前 N 列0 或省略 = 该维度不冻结)。",
"properties": {
"cols": {
"minimum": 0,
"type": "integer"
},
"rows": {
"minimum": 0,
"type": "integer"
}
},
"type": "object"
},
"name": {
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1其 name 会被忽略)。",
"type": "string"
},
"row_sizes": {
"description": "行高操作数组range 使用行范围如 1:3给 sizepx即像素行高type 可省略type 为 standard/auto 时不带 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard",
"auto"
],
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
@@ -8228,12 +8581,16 @@
"type": "array"
},
"cell_styles": {
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。加边框优先用 border 简写;只有分侧不同样式才用 border_styles 完整形态。",
"items": {
"properties": {
"background_color": {
"type": "string"
},
"border": {
"description": "边框简写(推荐):{style, weight, color} 应用到四边(如 {\"style\":\"solid\",\"color\":\"#DDDDDD\"});也接受侧键形态 {top:{…},bottom:{…}}。分侧不同样式用 border_styles 完整形态。",
"type": "object"
},
"border_styles": {
"type": "object",
"description": "边框配置,结构同 +cells-set-style --border-styles。",
@@ -8427,7 +8784,7 @@
"type": "array"
},
"col_sizes": {
"description": "列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size。",
"description": "列宽操作数组range 使用列范围如 A:C给 sizepx即像素列宽type 可省略);type 为 standard 时不带 size。",
"items": {
"properties": {
"range": {
@@ -8445,19 +8802,32 @@
}
},
"required": [
"range",
"type"
"range"
],
"type": "object"
},
"type": "array"
},
"freeze": {
"description": "冻结行列rows = 冻结前 N 行cols = 冻结前 N 列0 或省略 = 该维度不冻结)。",
"properties": {
"cols": {
"minimum": 0,
"type": "integer"
},
"rows": {
"minimum": 0,
"type": "integer"
}
},
"type": "object"
},
"name": {
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1其 name 会被忽略)。",
"type": "string"
},
"row_sizes": {
"description": "行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size。",
"description": "行高操作数组range 使用行范围如 1:3给 sizepx即像素行高type 可省略);type 为 standard/auto 时不带 size。",
"items": {
"properties": {
"range": {
@@ -8476,8 +8846,7 @@
}
},
"required": [
"range",
"type"
"range"
],
"type": "object"
},

View File

@@ -16,7 +16,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", Input: []string{"file", "stdin"}},
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Fail-fast by default: the first failure aborts the remaining operations and already-applied sub-operations are NOT rolled back (on \"N succeeded, M failed\" resend only the failed tail, not the whole batch); pass --continue-on-error to keep going past failures; no nesting; executed serially.", Input: []string{"file", "stdin"}},
{Name: "continue-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template for each sub-operation; no network side effects"},
@@ -75,9 +75,11 @@ 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: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)", Enum: []string{"value", "formula", "style", "comment", "data_validation", "truncation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.", Default: "500000"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden or collapsed rows and columns. Default `false`; when `--skip-filter` is omitted, filtered-out rows follow this value for backward compatibility"},
{Name: "skip-filter", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip filtered-out rows. When omitted, inherits `--skip-hidden`; explicitly set to `false` to keep filtered-out rows while skipping hidden rows and columns"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -133,10 +135,11 @@ 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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Write range (A1 notation)"},
{Name: "cells", Kind: "own", Type: "string", Required: "required", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`); not accepted with `--writes` (each writes item carries its own sheet selector)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`); not accepted with `--writes` (each writes item carries its own sheet selector)"},
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Write range (A1 notation). XOR with `--writes` (single region: --range+--cells; multiple regions: --writes)"},
{Name: "cells", Kind: "own", Type: "string", Required: "xor", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
{Name: "writes", Kind: "own", Type: "string", Required: "xor", Desc: "Multi-region write as a JSON array (up to 100 items), each `{sheet_name|sheet_id, range, cells}` — the sheet selector LIVES IN EACH ITEM (same convention as +batch-update sub-ops and +styles-put items; the top-level --sheet-name is rejected). cells has the same shape as `--cells` (2D array; per-cell cell_styles/border_styles allowed). The whole array goes out as ONE atomic batched request, cross-sheet supported; typical use: fixing formulas scattered across ranges/sheets — do not assemble a +batch-update operations array for this. XOR with `--range`+`--cells`; range-level uniform styling stays with +styles-put afterwards", Input: []string{"file", "stdin"}},
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", Default: "true"},
{Name: "max-cells", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 50000", Default: "50000", Hidden: true},
{Name: "copy-to-range", Kind: "own", Type: "string", Required: "optional", Desc: "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'."},
@@ -316,8 +319,9 @@ 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: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet). Optional: when omitted the whole sheet is read (clipped to the actual grid bounds; actual_range in the response names what was read); pair with --max-chars / --output-path on large sheets"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.", Default: "500000"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
@@ -344,7 +348,8 @@ 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: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"},
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`. XOR with `--ranges`"},
{Name: "ranges", Kind: "own", Type: "string", Required: "xor", Desc: "Multiple row/column ranges to delete as a JSON array (up to 100 items, e.g. `[\"5:5\",\"8:8\",\"11:13\"]` or `[\"C:C\",\"F:G\"]`); rows and columns cannot be mixed, ranges must not overlap; XOR with `--range`. CLI sorts positions in DESCENDING order into one atomic batch delete — ascending deletion would shift later indexes as earlier rows/columns disappear; the CLI handles the ordering", Input: []string{"file", "stdin"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -392,7 +397,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: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", Default: "none", Enum: []string{"before", "after", "none"}},
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from the preceding row/column) / `after` (from the following row/column). Omit the flag to inherit the following row/column (same as `after`) — the backend cannot leave a new row/column unstyled; for a truly blank row/column, clear formats afterwards with +cells-clear --scope formats. Insertion always lands before `--position`; this only selects which side's style is copied.", Enum: []string{"before", "after"}},
{Name: "position", Kind: "own", Type: "string", Required: "required", Desc: "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position"},
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Number of rows/columns to insert (must be > 0)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -975,6 +980,15 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+styles-put": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (target sheets are named inside --styles items)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "styles", Kind: "own", Type: "string", Required: "required", Desc: "Visual spec JSON applied to an EXISTING spreadsheet: top-level `{styles:[...]}`, one item per target sheet (`name` is the real sheet name), each giving at least one of `cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze`. The vocabulary is identical to `--styles` on `+workbook-create` / `+table-put` (cell_styles = A1 range + flat style fields, borders via the `border` shorthand {style,weight,color} applied to all four sides — border_styles only for per-side differences; row/col sizes = row/column range + size in px — type only for standard/auto; merges = cell range; freeze = `{rows:N, cols:N}`). The whole spec expands into one atomic batched request; ranges may target any region of the sheet", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the batched request template for each expanded operation; no network side effects"},
},
},
"+table-get": {
Risk: "read",
Flags: []flagDef{
@@ -983,6 +997,8 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). The underlying tool truncates at ~50000 even when unset, so this is sent explicitly to raise it; for a full untruncated read use --output-path (auto-unlimited).", Default: "500000"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},

View File

@@ -52,7 +52,7 @@ func TestFlagsFor_MapsAllFields(t *testing.T) {
// enum + default
rt := byName("+dim-insert", "inherit-style")
if rt == nil || len(rt.Enum) != 3 || rt.Default != "none" {
if rt == nil || len(rt.Enum) != 2 || rt.Default != "" {
t.Errorf("+dim-insert --inherit-style not mapped: %+v", rt)
}
// required

View File

@@ -38,9 +38,104 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
}
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
chainEnumNormalization(cmd)
chainFlagAliases(cmd)
}
}
// ─── intuitive flag names: silent aliases & prescriptions ───────────────
//
// Eval traces show unknown-flag failures cluster on a handful of habitual
// names (--file, --cols, --dimension, --start-cell, --bold, --source…) that
// agents import from generic CLI / Excel vocabulary. Two tiers, mirroring
// the enum-normalization contract above: a name whose value semantics are
// identical to the real flag is rewritten silently (zero round-trips); a
// name whose fix changes the value or moves it into a JSON field gets a
// curated prescription on the unknown-flag error instead — never a silent
// rewrite.
// commandFlagAliases maps, per command, habitual flag names onto the flag
// actually registered. Only pairs with identical value semantics belong
// here: the rewrite is invisible, so it must be safe to apply unread
// (+csv-put --file with a path value still trips the file-path guard, which
// prescribes @file / stdin).
var commandFlagAliases = map[string]map[string]string{
"+csv-put": {"file": "csv"},
"+sheet-create": {"name": "title"},
// size → width/height: the styles protocol (--styles row_sizes/col_sizes)
// spells the pixel dimension "size", and pre-2026-07 batches accepted it
// here too — the rename is the single largest sub-op error cluster in
// eval traces (15+ hits). Same pixel-count semantics, safe to rewrite.
"+cols-resize": {"cols": "range", "size": "width"},
"+rows-resize": {"rows": "range", "size": "height"},
"+range-fill": {"source": "source-range", "target": "target-range"},
"+range-copy": {"source": "source-range", "target": "target-range"},
"+range-move": {"source": "source-range", "target": "target-range"},
}
// intuitiveFlagHints carries the prescription for habitual names whose fix
// is not a 1:1 rename — the value belongs to a different flag or to a field
// inside a JSON payload. The hint spells the exact correct form so the
// retry needs no --help round trip.
var intuitiveFlagHints = map[string]map[string]string{
"+sheet-copy": {
"new-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
"target-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
"new-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
},
"+dim-insert": {
"dimension": "+dim-insert infers rows vs columns from --position: a row number like 3 inserts rows, a column letter like C inserts columns; pair with --count N",
},
"+dim-freeze": {
"frozen-rows": "freeze the first N rows with --dimension row --count N",
"frozen-cols": "freeze the first N columns with --dimension column --count N",
"frozen-columns": "freeze the first N columns with --dimension column --count N",
},
"+cells-set-style": {
"bold": "use --font-weight bold",
"italic": "use --font-style italic",
"underline": "use --font-line underline",
},
"+cells-set": {
// Predictable prior from +table-put --styles: models will try to
// attach range-level styling to a --writes call the same way.
"styles": `range-level styling goes through +styles-put (same {"styles":[...]} vocabulary); per-cell styles ride inside the cells objects as cell_styles`,
},
"+table-put": {
"start-cell": `anchor each sub-sheet via the "start_cell" field inside --sheets (e.g. {"sheets":[{"name":"Sheet1","start_cell":"B2",…}]}); to paste CSV at a cell use +csv-put --start-cell`,
"sheet-name": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
"sheet-id": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
},
}
// chainFlagAliases composes two rewrites onto the flag-name normalize hook
// (on top of any hook a prior PostMount installed, e.g. --token →
// --spreadsheet-token): the wire-vocabulary underscore form of any flag
// (--sheet_name, --border_styles — no sheets flag has an underscore in its
// canonical name), and the command's intuitive-alias table. Either way a
// habitual name parses as the real flag with zero round trips. Aliases
// never shadow a registered flag and never appear in --help; an alias whose
// target vanished (spec-side rename) is dropped, degrading to the
// unknown-flag prescription.
func chainFlagAliases(cmd *cobra.Command) {
aliases := commandFlagAliases[cmd.Name()]
usable := make(map[string]string, len(aliases))
for alias, target := range aliases {
if cmd.Flags().Lookup(alias) == nil && cmd.Flags().Lookup(target) != nil {
usable[alias] = target
}
}
prev := cmd.Flags().GetNormalizeFunc()
cmd.Flags().SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
if strings.Contains(name, "_") {
name = strings.ReplaceAll(name, "_", "-")
}
if target, ok := usable[name]; ok {
name = target
}
return prev(fs, name)
})
}
// 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
@@ -50,6 +145,19 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
// immediately.
func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagFromParseError(ferr)
// Targeted fix for a high-frequency agent mistake: +batch-update carries no
// top-level sheet locator (each sub-op names its own sheet inside its input),
// yet agents reach for --sheet-id / --sheet-name at the top level. An
// edit-distance suggestion would only mislead here, so skip it and name the
// real contract instead. Underscore spellings (--sheet_id) are matched too:
// the error message itself teaches the underscore key names, and sub-op
// inputs accept them, so agents mix the two styles.
locatorName := strings.ReplaceAll(name, "_", "-")
if isUnknown && c.Name() == "+batch-update" && (locatorName == "sheet-id" || locatorName == "sheet-name") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"batch-update has no top-level sheet locator; put sheet_id/sheet_name inside each operation's input").
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag"})
}
if !isUnknown {
return common.ValidationErrorf("%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
@@ -67,6 +175,14 @@ func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
strings.Join(suggestions, ", "), list)
}
}
// A curated prescription beats both: it spells the exact correct form
// for a habitual name whose fix is not a rename (see intuitiveFlagHints).
if rx, ok := intuitiveFlagHints[c.Name()][name]; ok {
hint = rx
if list := inlineFlagList(valid); list != "" {
hint = rx + "; valid flags: " + list
}
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
@@ -139,6 +255,16 @@ var enumAliases = map[string]string{
"center": "middle", // CSS vertical-align: center → Lark "middle"
"centre": "center",
"middle": "center", // CSS-style middle → Lark horizontal "center"
// Raw Lark OpenAPI merge vocabulary (MERGE_ALL/…) — agents reproduce it
// from the API docs; lowercased by canonicalEnumValue before lookup.
"merge_all": "all",
"merge_rows": "rows",
"merge_columns": "columns",
// Boolean-style word-wrap habits: true unambiguously means wrap on;
// false means "don't wrap", whose Lark default is overflow (word-clip is
// a distinct truncation mode nobody spells "false").
"true": "auto-wrap",
"false": "overflow",
}
// canonicalEnumValue returns the enum entry an off-vocabulary value

View File

@@ -96,6 +96,55 @@ func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) {
}
}
// TestSheetsFlagErrorFunc_BatchUpdateSheetLocator pins the targeted fix: a
// top-level --sheet-id / --sheet-name on +batch-update points the caller at
// the per-op locator contract instead of offering a misleading fuzzy guess.
func TestSheetsFlagErrorFunc_BatchUpdateSheetLocator(t *testing.T) {
t.Parallel()
for _, name := range []string{"sheet-id", "sheet-name", "sheet_id", "sheet_name"} {
name := name
t.Run(name, func(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "+batch-update"}
c.Flags().String("operations", "", "")
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --"+name))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if !strings.Contains(verr.Message, "put sheet_id/sheet_name inside each operation's input") {
t.Errorf("message should name the per-op locator contract, got %q", verr.Message)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("must not offer a fuzzy guess here, got hint %q", verr.Hint)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--"+name {
t.Errorf("Params should carry the offending flag, got %v", verr.Params)
}
if len(verr.Params[0].Suggestions) != 0 {
t.Errorf("no suggestions expected, got %v", verr.Params[0].Suggestions)
}
})
}
}
// TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests confirms the
// special case is scoped to the two sheet-locator flags: any other unknown
// flag on +batch-update keeps the normal did-you-mean behaviour.
func TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "+batch-update"}
c.Flags().String("operations", "", "")
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --operation"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if strings.Contains(verr.Message, "no top-level sheet locator") {
t.Errorf("non-locator unknown flag must not hit the special case, got %q", verr.Message)
}
}
func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "demo"}
@@ -284,9 +333,9 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--cols", "A:D",
"--col-size", "A:D",
})
ve := requireValidation(t, err, `unknown flag "--cols"`)
ve := requireValidation(t, err, `unknown flag "--col-size"`)
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)
@@ -294,3 +343,191 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
}
})
}
// TestShortcuts_IntuitiveFlagAliases verifies the silent-alias tier: a
// habitual name with identical value semantics parses as the real flag on a
// mounted command, costing zero round trips (eval: --cols, --file, --name,
// --source/--target each burned an unknown-flag failure plus a --help call).
func TestShortcuts_IntuitiveFlagAliases(t *testing.T) {
t.Parallel()
t.Run("cols-resize --cols parses as --range", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cols-resize")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--cols", "A:D",
"--width", "100",
"--dry-run",
})
if err != nil {
t.Fatalf("--cols should alias to --range and pass, got: %v", err)
}
if !strings.Contains(stdout, "A:D") {
t.Errorf("dry-run body should carry the aliased range, got %q", stdout)
}
})
t.Run("sheet-create --name parses as --title", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+sheet-create")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--name", "汇总",
"--dry-run",
})
if err != nil {
t.Fatalf("--name should alias to --title and pass, got: %v", err)
}
if !strings.Contains(stdout, "汇总") {
t.Errorf("dry-run body should carry the aliased title, got %q", stdout)
}
})
t.Run("range-fill --source/--target parse as ranges", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+range-fill")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--source", "B2",
"--target", "B3:B10",
"--dry-run",
})
if err != nil {
t.Fatalf("--source/--target should alias to the -range flags, got: %v", err)
}
for _, want := range []string{"B2", "B3:B10"} {
if !strings.Contains(stdout, want) {
t.Errorf("dry-run body should carry %q, got %q", want, stdout)
}
}
})
t.Run("csv-put --file parses as --csv", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+csv-put")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--start-cell", "A1",
"--file", "a,b\n1,2",
"--dry-run",
})
if err != nil {
t.Fatalf("--file with CSV text should alias to --csv and pass, got: %v", err)
}
if !strings.Contains(stdout, "a,b") {
t.Errorf("dry-run body should carry the CSV text, got %q", stdout)
}
})
t.Run("cols-resize --size parses as --width", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cols-resize")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A:C",
"--size", "120",
"--dry-run",
})
if err != nil {
t.Fatalf("--size should alias to --width (styles-protocol vocabulary), got: %v", err)
}
if !strings.Contains(stdout, "120") {
t.Errorf("dry-run body should carry the pixel width 120, got %q", stdout)
}
})
t.Run("rows-resize --size parses as --height", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+rows-resize")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "1:3",
"--size", "36",
"--dry-run",
})
if err != nil {
t.Fatalf("--size should alias to --height (styles-protocol vocabulary), got: %v", err)
}
})
t.Run("alias never shadows a registered flag", func(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "+csv-put"}
c.Flags().String("csv", "", "")
c.Flags().String("file", "", "") // hypothetical real flag wins
chainFlagAliases(c)
if err := c.ParseFlags([]string{"--file", "x"}); err != nil {
t.Fatalf("parse: %v", err)
}
if got, _ := c.Flags().GetString("file"); got != "x" {
t.Errorf("registered --file should keep its own value, got %q", got)
}
if got, _ := c.Flags().GetString("csv"); got != "" {
t.Errorf("--csv must stay empty when --file is a real flag, got %q", got)
}
})
}
// TestShortcuts_IntuitiveFlagHints verifies the prescription tier: habitual
// names whose fix is not a rename answer with the exact correct form, so the
// retry needs no --help round trip (eval: +sheet-copy burned 3/3 post-error
// --help calls, +dim-insert kept failing even after reading help).
func TestShortcuts_IntuitiveFlagHints(t *testing.T) {
t.Parallel()
cases := []struct {
command string
args []string
wrong string
wantHint []string
}{
{
command: "+dim-insert",
args: []string{"--url", testURL, "--sheet-name", "s", "--dimension", "row"},
wrong: "--dimension",
wantHint: []string{"--position", "--count"},
},
{
command: "+dim-freeze",
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen-rows", "2"},
wrong: "--frozen-rows",
wantHint: []string{"--dimension row --count N"},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--bold", "true"},
wrong: "--bold",
wantHint: []string{"--font-weight bold"},
},
{
command: "+sheet-copy",
args: []string{"--url", testURL, "--sheet-name", "s", "--new-sheet-name", "副本"},
wrong: "--new-sheet-name",
wantHint: []string{"--title", "source sheet"},
},
{
command: "+table-put",
args: []string{"--url", testURL, "--sheets", "{}", "--start-cell", "B2"},
wrong: "--start-cell",
wantHint: []string{`"start_cell"`, "+csv-put"},
},
}
for _, tc := range cases {
t.Run(tc.command+" "+tc.wrong, func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, tc.command)
_, _, err := runShortcutCapturingErr(t, sc, tc.args)
ve := requireValidation(t, err, "unknown flag \""+tc.wrong+"\"")
for _, want := range tc.wantHint {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
}
}

View File

@@ -7,6 +7,7 @@ import (
_ "embed"
"encoding/json"
"sort"
"strings"
"sync"
"github.com/larksuite/cli/errs"
@@ -84,6 +85,13 @@ func commandsWithFlagSchema() map[string]struct{} {
// listing of introspectable flags; otherwise it returns the schema
// subtree JSON for the named flag, or an error if the flag is not
// registered.
//
// flagName also accepts a dotted path (properties.plotArea.axes): the
// first segment names the flag, the rest walk the schema's properties
// (descending through array items implicitly), returning just that
// subtree. Large schemas — chart-create's properties is ~1,750 pretty
// lines — otherwise force agents to page through the full dump for one
// nested field; eval traces show 25 such round trips in one batch.
func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
return func(flagName string) ([]byte, error) {
idx, err := loadFlagSchemas()
@@ -103,10 +111,19 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
return json.MarshalIndent(map[string]interface{}{
"shortcut": command,
"introspectable_flags": flags,
"hint": "run again with --flag-name <name> to dump the JSON Schema for that flag",
"hint": "run again with --flag-name <name> to dump that flag's JSON Schema, or a dotted path like <name>.plotArea.axes to dump just one subtree",
}, "", " ")
}
schema, ok := entry[flagName]
name, path := splitSchemaPath(flagName)
schema, ok := entry[name]
if !ok {
// Tolerate the wire-vocabulary underscore form (--flag-name
// border_styles for border-styles) — agents copy field names out
// of JSON payloads where underscores are canonical.
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
schema, ok = entry[alt]
}
}
if !ok {
flags := make([]string, 0, len(entry))
for f := range entry {
@@ -114,14 +131,121 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
}
sort.Strings(flags)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"no JSON Schema registered for %s --%s; available: %v", command, flagName, flags).
"no JSON Schema registered for %s --%s; available: %v", command, name, flags).
WithParam("--flag-name")
}
// Reformat for readability — schema files store compact JSON.
var pretty interface{}
if err := json.Unmarshal(schema, &pretty); err != nil {
return nil, err
}
if len(path) > 0 {
pretty, err = sliceSchemaByPath(pretty, name, path)
if err != nil {
return nil, err
}
}
// Reformat for readability — schema files store compact JSON.
return json.MarshalIndent(pretty, "", " ")
}
}
// splitSchemaPath splits a --flag-name value into the flag name and the
// optional dotted schema path after it.
func splitSchemaPath(flagName string) (string, []string) {
parts := strings.Split(flagName, ".")
return parts[0], parts[1:]
}
// sliceSchemaByPath walks a decoded JSON Schema along dotted path segments.
// Each segment matches a key under "properties"; array levels are descended
// implicitly through "items" (an explicit "items" segment also works), and
// oneOf branches are searched for the first one carrying the key. A miss
// errors with the keys actually available at that level so the caller can
// re-issue the path without a full dump.
func sliceSchemaByPath(schema interface{}, flagName string, path []string) (interface{}, error) {
node := schema
walked := flagName
for _, seg := range path {
next, ok := schemaChild(node, seg)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"no %q under %s; available keys: %v", seg, walked, schemaChildKeys(node)).
WithParam("--flag-name")
}
node = next
walked += "." + seg
}
return node, nil
}
// schemaChild resolves one path segment against a schema node, descending
// through items / oneOf wrappers as needed.
func schemaChild(node interface{}, seg string) (interface{}, bool) {
for depth := 0; depth < 8; depth++ {
m, ok := node.(map[string]interface{})
if !ok {
return nil, false
}
if seg == "items" {
if items, ok := m["items"]; ok {
return items, true
}
}
if props, ok := m["properties"].(map[string]interface{}); ok {
if child, ok := props[seg]; ok {
return child, true
}
}
if items, ok := m["items"]; ok {
node = items
continue
}
if branches, ok := m["oneOf"].([]interface{}); ok {
for _, b := range branches {
if child, ok := schemaChild(b, seg); ok {
return child, true
}
}
}
return nil, false
}
return nil, false
}
// schemaChildKeys lists the property keys reachable at a schema node (through
// items / oneOf wrappers), for the path-miss error.
func schemaChildKeys(node interface{}) []string {
seen := map[string]struct{}{}
var collect func(n interface{}, depth int)
collect = func(n interface{}, depth int) {
if depth > 8 {
return
}
m, ok := n.(map[string]interface{})
if !ok {
return
}
if props, ok := m["properties"].(map[string]interface{}); ok {
for k := range props {
seen[k] = struct{}{}
}
return
}
if items, ok := m["items"]; ok {
collect(items, depth+1)
return
}
if branches, ok := m["oneOf"].([]interface{}); ok {
for _, b := range branches {
collect(b, depth+1)
}
}
}
collect(node, 0)
keys := make([]string, 0, len(seen))
for k := range seen {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View File

@@ -9,6 +9,8 @@ import (
"fmt"
"sort"
"strings"
"github.com/larksuite/cli/internal/suggest"
)
// ─── schema-driven flag validation ────────────────────────────────────
@@ -94,7 +96,15 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
}
var schema schemaProperty
json.Unmarshal(raw, &schema)
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
c := &schemaErrorCollector{}
collectSchemaErrors(value, &schema, "", c)
if len(c.errs) == 0 {
return nil
}
vErr := c.errs[0]
if len(c.errs) == 1 {
// Single failure keeps the historical message byte-for-byte.
//
// 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.
@@ -107,18 +117,63 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
// 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 {
isTypeMismatch := errors.As(vErr, &tm)
if isTypeMismatch && 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)
}
}
// Deep type mismatches don't get a whole-shape skeleton (it wouldn't
// address the actual field), but if the field itself carries an enum /
// description, append that one line — same "fix on first retry" goal.
msg := vErr.Error()
if isTypeMismatch {
if suffix := tm.hintSuffix(); suffix != "" {
msg += "; " + suffix
}
}
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)
name, msg, command, name).WithCause(vErr)
}
return nil
// Multiple failures: report them all at once (numbered, each with its
// own inline teaching hint) so the agent fixes the whole payload in one
// retry instead of the fail-fast "fix one, hit the next" loop.
return sheetsValidationForFlag(name,
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
name, formatSchemaErrorList(c.errs), command, name).WithCause(vErr)
}
// formatSchemaErrorList renders collected failures as a numbered one-line
// list: "N validation errors: 1) …; 2) …". Type-mismatch entries carry
// their enum/description suffix just like the single-error path. Entries
// beyond schemaErrorDisplayLimit collapse into a "(more …)" tail — the
// collector stops at cap, so the exact total is unknown by design.
func formatSchemaErrorList(errs []error) string {
shown := errs
truncated := false
if len(shown) > schemaErrorDisplayLimit {
shown = shown[:schemaErrorDisplayLimit]
truncated = true
}
parts := make([]string, 0, len(shown))
for i, e := range shown {
msg := e.Error()
var tm *typeMismatchError
if errors.As(e, &tm) {
if suffix := tm.hintSuffix(); suffix != "" {
msg += "; " + suffix
}
}
parts = append(parts, fmt.Sprintf("%d) %s", i+1, msg))
}
out := fmt.Sprintf("%d validation errors: %s", len(shown), strings.Join(parts, "; "))
if truncated {
out = fmt.Sprintf("%d+ validation errors: %s; (more errors not shown — fix these first)", schemaErrorDisplayLimit, strings.Join(parts, "; "))
}
return out
}
// validateInputAgainstSchema validates input[flag] for every flag the
@@ -187,8 +242,10 @@ var inputSchemaSkip = map[string]struct{}{
}
// schemaProperty mirrors the JSON Schema subset used by
// data/flag-schemas.json. Unknown keys (description, …) are dropped —
// they're documentation.
// data/flag-schemas.json. Description is retained (not just documentation)
// so a required-missing or type-mismatch error can inline the one-line
// field doc — the agent then fixes the input without a --print-schema round
// trip. Other unknown keys stay dropped.
//
// Minimum / Maximum / MinItems / MaxItems use *float64 / *int because
// 0 is a meaningful bound (e.g. chart row >= 0); nil distinguishes
@@ -204,6 +261,7 @@ var inputSchemaSkip = map[string]struct{}{
// map<string, array<string>> fields (groups / collapse).
type schemaProperty struct {
Type string `json:"type"`
Description string `json:"description"`
Nullable bool `json:"nullable"`
Enum []interface{} `json:"enum"`
Properties map[string]*schemaProperty `json:"properties"`
@@ -242,20 +300,66 @@ func (a *additionalProps) UnmarshalJSON(data []byte) error {
return nil
}
// schemaErrorCollector accumulates validation failures during one full
// traversal so the caller can report every problem in a single reply
// instead of the fail-fast "fix one, retry, hit the next" loop. Capacity
// is bounded (collectSchemaErrorsCap) so a pathological payload — e.g. a
// 5000-row --cells array where every cell is malformed — cannot balloon
// the error message or the traversal cost: once full, collection
// short-circuits everywhere via full().
type schemaErrorCollector struct {
errs []error
}
// collectSchemaErrorsCap bounds how many errors one traversal gathers:
// schemaErrorDisplayLimit entries are rendered; one extra is collected
// only to know that truncation happened.
const (
schemaErrorDisplayLimit = 5
collectSchemaErrorsCap = schemaErrorDisplayLimit + 1
)
func (c *schemaErrorCollector) add(err error) {
if len(c.errs) < collectSchemaErrorsCap {
c.errs = append(c.errs, err)
}
}
func (c *schemaErrorCollector) full() bool { return len(c.errs) >= collectSchemaErrorsCap }
// validateAgainstSchema recursively checks `value` against `schema`,
// prefixing any failure with the JSON path navigated so far.
// prefixing any failure with the JSON path navigated so far. It reports
// only the first failure — callers that want the full list (the
// error-as-teaching aggregate path) use collectSchemaErrors directly.
func validateAgainstSchema(value interface{}, schema *schemaProperty, path string) error {
if schema == nil {
return nil // defensive — current callers always pass &schema, but
// keeps validator safe for future programmatic construction.
c := &schemaErrorCollector{}
collectSchemaErrors(value, schema, path, c)
if len(c.errs) == 0 {
return nil
}
return c.errs[0]
}
// collectSchemaErrors is the traversal engine behind validateAgainstSchema:
// same checks, same messages, same deterministic order, but it keeps
// walking after a failure and appends every problem to the collector
// (until cap). Two deliberate exceptions to "keep walking":
// - a type mismatch stops descent into that node (its children would
// produce cascading nonsense against the wrong-typed value);
// - oneOf alternatives are probed with throwaway collectors (a failed
// alternative is not an error when a later one matches).
func collectSchemaErrors(value interface{}, schema *schemaProperty, path string, c *schemaErrorCollector) {
if schema == nil || c.full() {
return
}
if value == nil && schema.Nullable {
return nil
return
}
if schema.Type != "" {
if !matchesJSONType(value, schema.Type) {
return &typeMismatchError{path: path, expected: schema.Type, got: jsType(value)}
c.add(&typeMismatchError{path: path, expected: schema.Type, got: jsType(value), enum: schema.Enum, description: schema.Description})
return // wrong container type — descending would cascade nonsense.
}
}
@@ -263,20 +367,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) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
c.add(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) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
c.add(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) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
c.add(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) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
c.add(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
}
}
@@ -294,20 +398,22 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if hint := suggestEnumForError(value, schema.Enum); hint != "" {
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
}
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
if len(schema.OneOf) > 0 {
matched := false
for _, sub := range schema.OneOf {
if validateAgainstSchema(value, sub, path) == nil {
probe := &schemaErrorCollector{}
collectSchemaErrors(value, sub, path, probe)
if len(probe.errs) == 0 {
matched = true
break
}
}
if !matched {
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
c.add(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
}
}
@@ -316,8 +422,18 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
// the schema also describes their per-key shape via `properties`.
if obj, ok := value.(map[string]interface{}); ok {
for _, key := range schema.Required {
if c.full() {
return
}
if _, present := obj[key]; !present {
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
msg := fmt.Sprintf("required property %q is missing at %s", key, pathOrRoot(path))
// Inline the missing field's type / one-line description / enum so
// the agent supplies a correctly-shaped value on the first retry
// instead of fetching the full schema.
if hint := schemaFieldHint(schema.Properties[key]); hint != "" {
msg += "; expected " + hint
}
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
if schema.Properties != nil {
@@ -327,6 +443,9 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
}
sort.Strings(keys)
for _, key := range keys {
if c.full() {
return
}
sub := schema.Properties[key]
v, present := obj[key]
if !present {
@@ -350,14 +469,12 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if path != "" {
child = path + "." + key
}
if err := validateAgainstSchema(v, sub, child); err != nil {
return err
}
collectSchemaErrors(v, sub, child, c)
}
}
// additionalProperties: enforce only when explicitly declared.
// Absent means lenient (matches the file header's stance). Sort
// extras so the first rejection is deterministic across runs.
// extras so rejection order is deterministic across runs.
if schema.AdditionalProperties != nil {
extras := make([]string, 0)
for key := range obj {
@@ -368,17 +485,29 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
}
sort.Strings(extras)
for _, key := range extras {
if c.full() {
return
}
if schema.AdditionalProperties.Strict {
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
msg := fmt.Sprintf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
// Inline the node's declared keys (and a did-you-mean when the
// unknown key is a near miss) so the agent renames it in one
// retry instead of a --print-schema round trip.
if legal := sortedSchemaPropertyKeys(schema.Properties); len(legal) > 0 {
if guess := suggest.Closest(key, legal, 1); len(guess) > 0 {
msg += fmt.Sprintf(` (did you mean %q?)`, guess[0])
}
msg += "; valid properties: " + formatPropertyKeyList(legal)
}
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
continue
}
if schema.AdditionalProperties.Schema != nil {
child := key
if path != "" {
child = path + "." + key
}
if err := validateAgainstSchema(obj[key], schema.AdditionalProperties.Schema, child); err != nil {
return err
}
collectSchemaErrors(obj[key], schema.AdditionalProperties.Schema, child, c)
}
}
}
@@ -387,33 +516,50 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if schema.Type == "array" && schema.Items != nil {
arr, ok := value.([]interface{})
if !ok {
return nil // type mismatch already reported above.
return // type mismatch already reported above.
}
for i, item := range arr {
child := fmt.Sprintf("%s[%d]", path, i)
if err := validateAgainstSchema(item, schema.Items, child); err != nil {
return err
if c.full() {
return
}
child := fmt.Sprintf("%s[%d]", path, i)
collectSchemaErrors(item, schema.Items, child, c)
}
}
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.
// expected shape. Error() keeps the exact legacy wording; enum /
// description ride alongside for the deep-mismatch hintSuffix, so they
// never leak into the shallow-skeleton message.
type typeMismatchError struct {
path string
expected string
got string
path string
expected string
got string
enum []interface{}
description string
}
func (e *typeMismatchError) Error() string {
return fmt.Sprintf("%sexpected type %q, got %q", pathPrefix(e.path), e.expected, e.got)
}
// hintSuffix renders the field's description / enum as a one-line tail for
// the deep type-mismatch fallback (type is already stated by Error()).
// Empty when the field declares neither.
func (e *typeMismatchError) hintSuffix() string {
var parts []string
if d := oneLineDescription(e.description); d != "" {
parts = append(parts, "description: "+d)
}
if len(e.enum) > 0 {
parts = append(parts, "one of "+formatEnum(e.enum))
}
return strings.Join(parts, ", ")
}
// 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
@@ -605,6 +751,70 @@ func joinFormatted(values []interface{}) string {
return strings.Join(parts, ", ")
}
// schemaFieldHint renders a compact one-line "type X, description: …, one of
// […]" sketch of a single field's schema, used to enrich a required-missing
// error so the agent supplies a correctly-shaped value without --print-schema.
// Empty when the field declares none of type / description / enum.
func schemaFieldHint(s *schemaProperty) string {
if s == nil {
return ""
}
var parts []string
if s.Type != "" {
parts = append(parts, fmt.Sprintf("type %q", s.Type))
}
if d := oneLineDescription(s.Description); d != "" {
parts = append(parts, "description: "+d)
}
if len(s.Enum) > 0 {
parts = append(parts, "one of "+formatEnum(s.Enum))
}
return strings.Join(parts, ", ")
}
// sortedSchemaPropertyKeys returns the declared property names in a stable
// (sorted) order so the valid-property list in a strict unexpected-property
// error is deterministic across runs.
func sortedSchemaPropertyKeys(props map[string]*schemaProperty) []string {
keys := make([]string, 0, len(props))
for k := range props {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// propertyKeyDisplayLimit caps how many declared property names ride inline on
// a strict unexpected-property error, so a wide object doesn't bury the actual
// error under a wall of keys. Overflow is summarised as "(N more)".
const propertyKeyDisplayLimit = 15
func formatPropertyKeyList(keys []string) string {
if len(keys) <= propertyKeyDisplayLimit {
return "[" + strings.Join(keys, ", ") + "]"
}
shown := keys[:propertyKeyDisplayLimit]
return fmt.Sprintf("[%s, … (%d more)]", strings.Join(shown, ", "), len(keys)-propertyKeyDisplayLimit)
}
// descriptionMaxLen bounds an inlined field description to one reasonable line;
// schema descriptions can run several sentences, which would swamp the error.
const descriptionMaxLen = 120
// oneLineDescription collapses a (possibly multi-line) schema description into
// a single whitespace-normalised line, truncated to descriptionMaxLen runes.
// Returns "" for an empty / whitespace-only description.
func oneLineDescription(s string) string {
collapsed := strings.Join(strings.Fields(s), " ")
if collapsed == "" {
return ""
}
if r := []rune(collapsed); len(r) > descriptionMaxLen {
return string(r[:descriptionMaxLen]) + "…"
}
return collapsed
}
// 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

View File

@@ -5,6 +5,8 @@ package sheets
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
)
@@ -438,6 +440,373 @@ func TestValidateValueAgainstSchema_ShapeSkeletonOnShallowTypeMismatch(t *testin
}
}
// TestValidateAgainstSchema_StrictUnexpectedPropertyListsKeys pins the strict
// additionalProperties:false enhancement: the error lists the node's legal
// property keys (sorted, capped at 15 with an "(N more)" overflow) and, when
// the unknown key is a near miss, appends a did-you-mean.
func TestValidateAgainstSchema_StrictUnexpectedPropertyListsKeys(t *testing.T) {
t.Parallel()
t.Run("lists legal keys and suggests a near miss", func(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"object",
"additionalProperties":false,
"properties":{
"background_color":{"type":"string"},
"font_weight":{"type":"string"},
"font_size":{"type":"integer"}
}
}`)
err := validateAgainstSchema(map[string]interface{}{"background_colour": "#fff"}, schema, "")
if err == nil {
t.Fatal("unknown key under strict schema must fail")
}
msg := err.Error()
if !strings.Contains(msg, `unexpected property "background_colour"`) {
t.Errorf("want the offending key named; got %q", msg)
}
if !strings.Contains(msg, `did you mean "background_color"?`) {
t.Errorf("want a did-you-mean for the near miss; got %q", msg)
}
for _, want := range []string{"valid properties:", "background_color", "font_size", "font_weight"} {
if !strings.Contains(msg, want) {
t.Errorf("want valid-property list to contain %q; got %q", want, msg)
}
}
})
t.Run("no did-you-mean for an unrelated key", func(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"object",
"additionalProperties":false,
"properties":{"background_color":{"type":"string"}}
}`)
err := validateAgainstSchema(map[string]interface{}{"zzzzzzzz": 1}, schema, "")
if err == nil {
t.Fatal("unknown key must fail")
}
if strings.Contains(err.Error(), "did you mean") {
t.Errorf("unrelated key should get no suggestion; got %q", err.Error())
}
if !strings.Contains(err.Error(), "valid properties: [background_color]") {
t.Errorf("want the valid-property list; got %q", err.Error())
}
})
t.Run("wide object truncates the key list with overflow", func(t *testing.T) {
t.Parallel()
props := make([]string, 0, 20)
for i := 0; i < 20; i++ {
props = append(props, fmt.Sprintf(`"k%02d":{"type":"string"}`, i))
}
schema := parseSchema(t, `{"type":"object","additionalProperties":false,"properties":{`+strings.Join(props, ",")+`}}`)
err := validateAgainstSchema(map[string]interface{}{"nope": 1}, schema, "")
if err == nil {
t.Fatal("unknown key must fail")
}
if !strings.Contains(err.Error(), "(5 more)") { // 20 keys, cap 15
t.Errorf("want overflow marker '(5 more)'; got %q", err.Error())
}
})
}
// TestValidateAgainstSchema_RequiredMissingInlinesFieldHint pins that a
// required-property-missing error inlines the field's type / one-line
// description / enum when the schema describes that field.
func TestValidateAgainstSchema_RequiredMissingInlinesFieldHint(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"object",
"required":["operation"],
"properties":{
"operation":{
"type":"string",
"description":"Which mutation to run.",
"enum":["insert","delete","move"]
}
}
}`)
err := validateAgainstSchema(map[string]interface{}{}, schema, "")
if err == nil {
t.Fatal("missing required property must fail")
}
msg := err.Error()
for _, want := range []string{
`required property "operation"`,
`type "string"`,
"description: Which mutation to run.",
`one of ["insert", "delete", "move"]`,
} {
if !strings.Contains(msg, want) {
t.Errorf("want %q in required-missing error; got %q", want, msg)
}
}
}
// TestValidateAgainstSchema_RequiredMissingNoSchemaStaysPlain pins that a
// missing required key with no describing schema keeps the plain legacy
// message (no trailing "expected ...").
func TestValidateAgainstSchema_RequiredMissingNoSchemaStaysPlain(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{"type":"object","required":["a"]}`)
err := validateAgainstSchema(map[string]interface{}{}, schema, "")
if err == nil {
t.Fatal("missing required must fail")
}
if strings.Contains(err.Error(), "; expected") {
t.Errorf("no field schema → no inlined hint; got %q", err.Error())
}
}
// TestValidateValueAgainstSchema_DeepTypeMismatchAppendsEnum pins that a deep
// type mismatch (past the skeleton depth limit) still gets no whole-shape
// skeleton, but appends the field's enum / description one-liner.
func TestValidateValueAgainstSchema_DeepTypeMismatchAppendsEnum(t *testing.T) {
t.Parallel()
// A wrong-typed value three levels deep where the field is an enum string.
schema := parseSchema(t, `{
"type":"array",
"items":{"type":"array","items":{"type":"object","properties":{
"align":{"type":"string","description":"Text alignment.","enum":["left","center","right"]}
}}}
}`)
deep := parseValue(t, `[[{"align":42}]]`)
err := validateAgainstSchema(deep, schema, "")
if err == nil {
t.Fatal("wrong type for align must fail")
}
var tm *typeMismatchError
if !errors.As(err, &tm) {
t.Fatalf("want *typeMismatchError, got %T", err)
}
suffix := tm.hintSuffix()
for _, want := range []string{"description: Text alignment.", `one of ["left", "center", "right"]`} {
if !strings.Contains(suffix, want) {
t.Errorf("want %q in hintSuffix; got %q", want, suffix)
}
}
}
// TestSchemaFieldHint covers the single-field sketch used by
// required-missing errors: each of type / description / enum contributes
// its own segment, absent parts are simply skipped, and a nil / empty
// schema yields no hint at all.
func TestSchemaFieldHint(t *testing.T) {
t.Parallel()
cases := []struct {
name string
schema *schemaProperty
want string
}{
{"nil schema", nil, ""},
{"empty schema", &schemaProperty{}, ""},
{"type only", &schemaProperty{Type: "string"}, `type "string"`},
{"description only", &schemaProperty{Description: "Cell note."}, "description: Cell note."},
{"enum only", &schemaProperty{Enum: []interface{}{"a", "b"}}, `one of ["a", "b"]`},
{
"all three",
&schemaProperty{Type: "string", Description: "段类型", Enum: []interface{}{"text", "link"}},
`type "string", description: 段类型, one of ["text", "link"]`,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := schemaFieldHint(tc.schema); got != tc.want {
t.Errorf("schemaFieldHint = %q, want %q", got, tc.want)
}
})
}
}
// TestFormatPropertyKeyList_Boundaries pins the display cap edges: exactly
// at the cap nothing is folded, one past the cap folds into "(1 more)".
func TestFormatPropertyKeyList_Boundaries(t *testing.T) {
t.Parallel()
keys := make([]string, 0, propertyKeyDisplayLimit+1)
for i := 0; i < propertyKeyDisplayLimit; i++ {
keys = append(keys, fmt.Sprintf("k%02d", i))
}
if got := formatPropertyKeyList(keys); strings.Contains(got, "more)") {
t.Errorf("exactly %d keys must not fold, got %q", propertyKeyDisplayLimit, got)
}
keys = append(keys, "overflow")
if got := formatPropertyKeyList(keys); !strings.Contains(got, "(1 more)") {
t.Errorf("%d keys should fold into '(1 more)', got %q", propertyKeyDisplayLimit+1, got)
}
}
// TestTypeMismatchHintSuffix_EmptyWhenUndeclared pins that a field with
// neither enum nor description adds no suffix — the deep-mismatch fallback
// message must stay byte-identical to the legacy wording in that case.
func TestTypeMismatchHintSuffix_EmptyWhenUndeclared(t *testing.T) {
t.Parallel()
tm := &typeMismatchError{path: "a.b", expected: "string", got: "number"}
if got := tm.hintSuffix(); got != "" {
t.Errorf("no enum/description → empty suffix, got %q", got)
}
}
// TestValidateAgainstSchema_StrictUnexpectedProperty_CaseOnlyTypo pins the
// did-you-mean for a key that differs from a legal one only in casing /
// underscore style — a high-frequency LLM slip.
func TestValidateAgainstSchema_StrictUnexpectedProperty_CaseOnlyTypo(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"object",
"additionalProperties":false,
"properties":{"background_color":{"type":"string"}}
}`)
err := validateAgainstSchema(map[string]interface{}{"Background_Color": "#fff"}, schema, "")
if err == nil {
t.Fatal("case-typo key under strict schema must fail")
}
if !strings.Contains(err.Error(), `did you mean "background_color"?`) {
t.Errorf("want case-insensitive did-you-mean; got %q", err.Error())
}
}
// TestValidateValueAgainstSchema_RequiredMissingRealSchema replays 场景3
// of the doubao case against the real embedded flag-schemas.json: a
// rich_text segment without "type" must inline the field's type, enum and
// description while keeping the --print-schema pointer.
func TestValidateValueAgainstSchema_RequiredMissingRealSchema(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+cells-set"}
value := parseValue(t, `[[{"rich_text":[{"text":"x"}]}]]`)
err := validateValueAgainstSchema(fv, "cells", value)
if err == nil {
t.Fatal("rich_text without type must fail against the embedded schema")
}
msg := err.Error()
for _, want := range []string{
`required property "type" is missing`,
`expected type "string"`,
"one of [",
`"text"`,
"--print-schema",
} {
if !strings.Contains(msg, want) {
t.Errorf("want %q in real-schema required-missing error; got %q", want, msg)
}
}
}
// TestValidateValueAgainstSchema_DeepMismatchRealSchema replays 场景4: a
// numeric rich_text "type" three levels deep gets the field's enum inline
// (no whole-shape skeleton), still with the --print-schema pointer.
func TestValidateValueAgainstSchema_DeepMismatchRealSchema(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+cells-set"}
value := parseValue(t, `[[{"rich_text":[{"type":42,"text":"x"}]}]]`)
err := validateValueAgainstSchema(fv, "cells", value)
if err == nil {
t.Fatal("numeric rich_text type must fail against the embedded schema")
}
msg := err.Error()
for _, want := range []string{
`expected type "string", got "number"`,
"one of [",
`"text"`,
"--print-schema",
} {
if !strings.Contains(msg, want) {
t.Errorf("want %q in real-schema deep-mismatch error; got %q", want, msg)
}
}
if strings.Contains(msg, "expected shape:") {
t.Errorf("deep mismatch must not inline a skeleton; got %q", msg)
}
}
// TestValidateValueAgainstSchema_AggregatesMultipleErrors pins the
// aggregate path: a payload with several independent problems reports them
// all in one numbered reply (each with its own teaching hint) instead of
// the fail-fast fix-one-retry-hit-the-next loop.
func TestValidateValueAgainstSchema_AggregatesMultipleErrors(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+cells-set"}
// Two independent problems in one --cells payload: cell[0][0].rich_text[0]
// misses required "type"; cell[0][1].note has the wrong type.
value := parseValue(t, `[[{"rich_text":[{"text":"x"}]},{"note":12.5}]]`)
err := validateValueAgainstSchema(fv, "cells", value)
if err == nil {
t.Fatal("payload with two problems must fail")
}
msg := err.Error()
for _, want := range []string{
"2 validation errors:",
`1) required property "type" is missing`,
`one of ["text"`, // teaching hint rides along in aggregate mode too
`2) [0][1].note: expected type "string"`,
"--print-schema",
} {
if !strings.Contains(msg, want) {
t.Errorf("want %q in aggregated error; got %q", want, msg)
}
}
}
// TestValidateValueAgainstSchema_AggregateCapTruncates pins the display
// cap: a pathological payload reports schemaErrorDisplayLimit entries and
// an explicit truncation tail, never the full flood.
func TestValidateValueAgainstSchema_AggregateCapTruncates(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+cells-set"}
// Seven cells all missing required rich_text "type" → 7 independent errors.
row := make([]string, 0, 7)
for i := 0; i < 7; i++ {
row = append(row, `{"rich_text":[{"text":"x"}]}`)
}
value := parseValue(t, `[[`+strings.Join(row, ",")+`]]`)
err := validateValueAgainstSchema(fv, "cells", value)
if err == nil {
t.Fatal("payload with seven problems must fail")
}
msg := err.Error()
if !strings.Contains(msg, "5+ validation errors:") {
t.Errorf("want capped header '5+ validation errors:'; got %q", msg)
}
if !strings.Contains(msg, "more errors not shown") {
t.Errorf("want truncation tail; got %q", msg)
}
if strings.Contains(msg, "6)") {
t.Errorf("must not render entries beyond the display limit; got %q", msg)
}
}
// TestCollectSchemaErrors_OneOfProbeDoesNotLeak pins that failed oneOf
// alternatives don't leak probe errors into the caller's collector when a
// later alternative matches.
func TestCollectSchemaErrors_OneOfProbeDoesNotLeak(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{"oneOf":[{"type":"string"},{"type":"number"}]}`)
c := &schemaErrorCollector{}
collectSchemaErrors(42.0, schema, "", c)
if len(c.errs) != 0 {
t.Errorf("number matches the second oneOf alternative; want no errors, got %v", c.errs)
}
}
func TestOneLineDescription(t *testing.T) {
t.Parallel()
if got := oneLineDescription(" "); got != "" {
t.Errorf("whitespace-only → empty, got %q", got)
}
if got := oneLineDescription("line one\n line two"); got != "line one line two" {
t.Errorf("multi-line collapse = %q", got)
}
long := strings.Repeat("x", 200)
got := oneLineDescription(long)
if !strings.HasSuffix(got, "…") || len([]rune(got)) != descriptionMaxLen+1 {
t.Errorf("long description should truncate to %d runes + ellipsis, got %d", descriptionMaxLen, len([]rune(got)))
}
}
func TestPathDepth(t *testing.T) {
t.Parallel()
cases := []struct {

View File

@@ -34,6 +34,7 @@ var commandsWithSchema = map[string]struct{}{
"+rows-resize": {},
"+sparkline-create": {},
"+sparkline-update": {},
"+styles-put": {},
"+table-put": {},
"+workbook-create": {},
}

View File

@@ -11,7 +11,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
neturl "net/url"
"strings"
@@ -407,6 +406,13 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
}
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
}
// Unambiguous habitual shapes are rewritten onto the wire contract
// before validation (see jsonFlagNormalizers). Runs on the parsed value,
// so both the standalone cobra path and +batch-update sub-ops (whose
// mapFlagView.Str re-encodes composites through here) get the rewrite.
if norm := jsonFlagNormalizers[runtime.Command()][name]; norm != nil {
out = norm(out)
}
// Schema-driven flag validation at the user-input boundary. Skips
// --properties (validated at the input-builder tail after enhance
// hooks fill in flat-flag-derived fields) and any flag without an
@@ -417,6 +423,92 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
return out, nil
}
// jsonFlagNormalizers rewrites, per (command, flag), unambiguous habitual
// input shapes onto the wire contract before schema validation — same
// contract as enum normalization: only a shape whose meaning is beyond
// doubt may be rewritten; anything ambiguous must fail with a prescription
// instead. Applied to the parsed JSON value inside parseJSONFlag.
var jsonFlagNormalizers = map[string]map[string]func(interface{}) interface{}{
"+cells-set": {"cells": wrapLoneCellObject},
"+chart-create": {"properties": normalizeChartHexColors},
"+chart-update": {"properties": normalizeChartHexColors},
}
// normalizeChartHexColors walks a chart properties payload and prefixes bare
// 6/8-digit hex values on color keys with '#' (4472C4 → #4472C4 — the
// Excel-habit form the chart backend rejects with "expected rgba() or
// #RRGGBB/#RRGGBBAA"). In-place, recursive; anything not unambiguously a
// bare hex color is untouched.
func normalizeChartHexColors(v interface{}) interface{} {
switch t := v.(type) {
case map[string]interface{}:
for k, val := range t {
if s, ok := val.(string); ok && isColorKey(k) && isBareHexColor(s) {
t[k] = "#" + s
continue
}
normalizeChartHexColors(val)
}
case []interface{}:
for _, e := range t {
normalizeChartHexColors(e)
}
}
return v
}
func isColorKey(k string) bool {
return k == "color" || strings.HasSuffix(k, "_color") || strings.HasSuffix(k, "Color")
}
func isBareHexColor(s string) bool {
if len(s) != 6 && len(s) != 8 {
return false
}
for _, r := range s {
switch {
case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
default:
return false
}
}
return true
}
// cellObjectKeys pins the property vocabulary of a single cell in the
// +cells-set --cells schema ([[{…}]]). Drift against the embedded schema is
// guarded by TestCellObjectKeys_MatchEmbeddedSchema.
var cellObjectKeys = map[string]struct{}{
"border_styles": {},
"cell_styles": {},
"data_validation": {},
"formula": {},
"multiple_values": {},
"note": {},
"rich_text": {},
"value": {},
}
// wrapLoneCellObject rewrites a bare cell object into the [[cell]] the
// --cells contract expects. Eval traces show agents writing a single cell
// routinely pass {"value":…} without the two array layers; when every key
// belongs to the cell vocabulary the meaning is a 1×1 write and the wrap is
// safe. Anything else (unknown keys, arrays — one bracket layer could be a
// row or a column) is returned untouched for the schema validator to
// prescribe.
func wrapLoneCellObject(v interface{}) interface{} {
obj, ok := v.(map[string]interface{})
if !ok || len(obj) == 0 {
return v
}
for k := range obj {
if _, known := cellObjectKeys[k]; !known {
return v
}
}
return []interface{}{[]interface{}{obj}}
}
// requireJSONObject is parseJSONFlag + a type assertion to map[string]interface{}.
func requireJSONObject(runtime flagView, name string) (map[string]interface{}, error) {
v, err := parseJSONFlag(runtime, name)
@@ -448,146 +540,3 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
}
return a, nil
}
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
// 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{} {
style := map[string]interface{}{}
if v := runtime.Str("background-color"); v != "" {
style["background_color"] = v
}
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")
}
if v := runtime.Str("font-style"); v != "" {
style["font_style"] = v
}
if v := runtime.Str("font-weight"); v != "" {
style["font_weight"] = v
}
if v := runtime.Str("font-line"); v != "" {
style["font_line"] = v
}
if v := runtime.Str("horizontal-alignment"); v != "" {
style["horizontal_alignment"] = v
}
if v := runtime.Str("vertical-alignment"); v != "" {
style["vertical_alignment"] = v
}
if v := runtime.Str("word-wrap"); v != "" {
style["word_wrap"] = v
}
if v := runtime.Str("number-format"); v != "" {
style["number_format"] = v
}
return style
}
// cellStyleAliases maps shorthand cell_styles field names that models commonly
// hallucinate (Excel / openpyxl / CSS conventions) onto the canonical field
// names the backend expects. Only the unambiguous alignment shorthands are
// aliased — they are the high-frequency miss; ambiguous guesses (e.g. "color",
// "bg_color", "text_align") are intentionally left out so a wrong guess still
// surfaces as an error rather than being silently reinterpreted.
var cellStyleAliases = []struct{ alias, canonical string }{
{"horizontal_align", "horizontal_alignment"},
{"halign", "horizontal_alignment"},
{"vertical_align", "vertical_alignment"},
{"valign", "vertical_alignment"},
}
// normalizeCellStyleAliases renames known shorthand keys in a single
// cell_styles map to their canonical equivalents, in place, so a model that
// writes e.g. "horizontal_align" instead of "horizontal_alignment" still
// applies the style instead of hitting an "unsupported field" error (--styles)
// or having the field silently dropped by the backend (typed --cells). If both
// the shorthand and its canonical key are present it returns a validation error
// rather than picking one. path labels the map for the error message.
func normalizeCellStyleAliases(style map[string]interface{}, path string) error {
if len(style) == 0 {
return nil
}
for _, a := range cellStyleAliases {
v, ok := style[a.alias]
if !ok {
continue
}
if _, exists := style[a.canonical]; exists {
return common.ValidationErrorf("%s.%s conflicts with %s; pass only %s", path, a.alias, a.canonical, a.canonical)
}
style[a.canonical] = v
delete(style, a.alias)
}
return nil
}
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
// alignment shorthands are accepted on +cells-set the same as on --styles.
// Structure is checked leniently to match the pass-through contract: any
// element that isn't the expected shape is skipped, not rejected.
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
for r, rowRaw := range cells {
row, ok := rowRaw.([]interface{})
if !ok {
continue
}
for c, cellRaw := range row {
cell, ok := cellRaw.(map[string]interface{})
if !ok {
continue
}
st, ok := cell["cell_styles"].(map[string]interface{})
if !ok {
continue
}
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
return err
}
}
}
return nil
}
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
// left/right with style sub-objects). Returns nil when the flag is empty.
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
if runtime.Str("border-styles") == "" {
return nil, nil
}
v, err := parseJSONFlag(runtime, "border-styles")
if err != nil {
return nil, err
}
m, ok := v.(map[string]interface{})
if !ok {
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
}
return m, nil
}
// requireAnyStyleFlag ensures at least one style-defining flag (style or
// border) is set — otherwise the request would do nothing.
func requireAnyStyleFlag(runtime flagView) error {
if len(buildCellStyleFromFlags(runtime)) > 0 {
return nil
}
if runtime.Str("border-styles") != "" {
return nil
}
return common.ValidationErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)").
WithParams(
sheetsInvalidParam("background-color", "required; specify at least one style flag"),
sheetsInvalidParam("font-weight", "required; specify at least one style flag"),
sheetsInvalidParam("border-styles", "required; specify at least one style flag"),
)
}

View File

@@ -0,0 +1,209 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
)
// TestWrapLoneCellObject pins the auto-wrap contract: a bare cell object —
// the classic missing-[[…]] shape agents produce for a 1×1 write — is
// rewritten to [[cell]]; anything whose meaning is not beyond doubt stays
// untouched for the schema validator to prescribe.
func TestWrapLoneCellObject(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
wrapped bool
}{
{"lone value cell", `{"value":"hi"}`, true},
{"lone formula cell with styles", `{"formula":"=SUM(A1:A3)","cell_styles":{"font_weight":"bold"}}`, true},
{"unknown key stays", `{"value":"hi","range":"A1"}`, false},
{"array of cells stays (row vs column ambiguous)", `[{"value":"a"},{"value":"b"}]`, false},
{"proper 2D array stays", `[[{"value":"a"}]]`, false},
{"empty object stays", `{}`, false},
{"scalar stays", `"hi"`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var v interface{}
if err := json.Unmarshal([]byte(tc.in), &v); err != nil {
t.Fatalf("bad fixture: %v", err)
}
out := wrapLoneCellObject(v)
_, isWrapped := out.([]interface{})
_, wasArray := v.([]interface{})
if tc.wrapped && (!isWrapped || wasArray) {
t.Errorf("expected wrap to [[cell]], got %#v", out)
}
if !tc.wrapped && !wasArray && isWrapped {
t.Errorf("expected no wrap, got %#v", out)
}
if tc.wrapped {
rows, _ := out.([]interface{})
if len(rows) != 1 {
t.Fatalf("want 1 row, got %d", len(rows))
}
cells, _ := rows[0].([]interface{})
if len(cells) != 1 {
t.Fatalf("want 1 cell, got %d", len(cells))
}
}
})
}
}
// TestCellObjectKeys_MatchEmbeddedSchema drift-guards the hardcoded cell
// vocabulary against the embedded +cells-set --cells schema: if the spec
// repo adds or removes a cell property, this fails and cellObjectKeys must
// be updated (an outdated set only narrows the auto-wrap, but silently
// narrowing is still drift).
func TestCellObjectKeys_MatchEmbeddedSchema(t *testing.T) {
t.Parallel()
idx, err := loadFlagSchemas()
if err != nil {
t.Fatalf("loadFlagSchemas: %v", err)
}
raw, ok := idx.Flags["+cells-set"]["cells"]
if !ok {
t.Fatal("embedded schema for +cells-set --cells missing")
}
var schema schemaProperty
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
cell := schema.Items
if cell != nil && cell.Items != nil {
cell = cell.Items
}
if cell == nil || len(cell.Properties) == 0 {
t.Fatal("schema shape changed: expected array→array→object with properties")
}
for k := range cell.Properties {
if _, ok := cellObjectKeys[k]; !ok {
t.Errorf("schema property %q missing from cellObjectKeys", k)
}
}
for k := range cellObjectKeys {
if _, ok := cell.Properties[k]; !ok {
t.Errorf("cellObjectKeys has %q which the schema no longer declares", k)
}
}
}
// TestCellsSet_LoneCellObjectAutoWraps runs the mounted path end-to-end: the
// eval-trace failure shape (--cells with a bare object) now dry-runs clean
// instead of failing "expected type array, got object".
func TestCellsSet_LoneCellObjectAutoWraps(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1",
"--cells", `{"value":"hello"}`,
"--dry-run",
})
if err != nil {
t.Fatalf("lone cell object should auto-wrap to [[cell]], got: %v", err)
}
if !strings.Contains(stdout, "hello") {
t.Errorf("dry-run body should carry the cell value, got %q", stdout)
}
}
// TestTablePut_SheetsDecodeHints pins the two decode-failure prescriptions:
// wrong JSON kind inlines the expected shape; mangled JSON steers to
// stdin/@file.
func TestTablePut_SheetsDecodeHints(t *testing.T) {
t.Parallel()
t.Run("type mismatch inlines skeleton", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+table-put")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"s","columns":[{"name":"a"}],"data":[]}]}`,
"--dry-run",
})
ve := requireValidation(t, err, "--sheets: invalid JSON")
for _, want := range []string{"expected shape:", `"columns":["City","Revenue"]`, `"dtypes":{"Revenue":"float64"}`} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
t.Run("syntax error steers to stdin or @file", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+table-put")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheets", `{"sheets":[)`,
"--dry-run",
})
ve := requireValidation(t, err, "--sheets: invalid JSON")
for _, want := range []string{"stdin", "@./payload.json"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
}
// TestNormalizeChartHexColors pins the '#' prefixing on bare hex color
// values (eval V2U024: bars.color "4472C4" rejected server-side) and the
// pass-through of everything else, including the parseJSONFlag wiring for
// the batch sub-op path.
func TestNormalizeChartHexColors(t *testing.T) {
t.Parallel()
props := map[string]interface{}{
"plotArea": map[string]interface{}{
"plot": map[string]interface{}{
"series": []interface{}{
map[string]interface{}{"bars": map[string]interface{}{"color": "4472C4"}},
map[string]interface{}{"line": map[string]interface{}{"color": "#ED7D31"}},
map[string]interface{}{"area": map[string]interface{}{"color": "rgba(1,2,3,0.5)"}},
map[string]interface{}{"font_color": "ED7D31AA", "label": "not a color 4472C4"},
},
},
},
}
normalizeChartHexColors(props)
series := props["plotArea"].(map[string]interface{})["plot"].(map[string]interface{})["series"].([]interface{})
if got := series[0].(map[string]interface{})["bars"].(map[string]interface{})["color"]; got != "#4472C4" {
t.Errorf("bare hex should gain #, got %v", got)
}
if got := series[1].(map[string]interface{})["line"].(map[string]interface{})["color"]; got != "#ED7D31" {
t.Errorf("already-prefixed color must not change, got %v", got)
}
if got := series[2].(map[string]interface{})["area"].(map[string]interface{})["color"]; got != "rgba(1,2,3,0.5)" {
t.Errorf("rgba color must not change, got %v", got)
}
last := series[3].(map[string]interface{})
if got := last["font_color"]; got != "#ED7D31AA" {
t.Errorf("8-digit hex on a *_color key should gain #, got %v", got)
}
if got := last["label"]; got != "not a color 4472C4" {
t.Errorf("non-color key must not change, got %v", got)
}
// Wiring: a +chart-create sub-op style view routes through parseJSONFlag
// and picks up the normalizer.
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{
"properties": map[string]interface{}{"title": map[string]interface{}{"font_color": "112233"}},
})
out, err := parseJSONFlag(fv, "properties")
if err != nil {
t.Fatalf("parseJSONFlag: %v", err)
}
title := out.(map[string]interface{})["title"].(map[string]interface{})
if title["font_color"] != "#112233" {
t.Errorf("parseJSONFlag should apply the chart color normalizer, got %v", title["font_color"])
}
}

View File

@@ -5,6 +5,7 @@ package sheets
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/shortcuts/common"
@@ -29,10 +30,14 @@ import (
// The tool's contract (post-translation):
// { excel_id, operations: [{tool_name, input}, ...], continue_on_error? }
//
// continue_on_error defaults to false (strict transaction): any failure
// rolls back the whole batch. CLI leaves the default in place for the
// three "fan-out" shortcuts since they're meant to be all-or-nothing;
// only +batch-update lets callers flip it via --continue-on-error.
// continue_on_error defaults to false (fail-fast): execution stops at the
// first failing sub-op, but sub-ops already applied are NOT rolled back —
// the server reports "N succeeded, M failed" and the N stay in the sheet
// (verified against live batches; earlier docs wrongly promised a rollback,
// which made agents resend whole batches and double-apply the successes).
// CLI leaves the default in place for the fan-out shortcuts since they're
// idempotent stamps; only +batch-update lets callers flip it via
// --continue-on-error.
// BatchUpdate accepts a CLI-shape operations array (each item
// {shortcut, input}); on Validate / DryRun / Execute we translate each
@@ -42,7 +47,7 @@ import (
var BatchUpdate = common.Shortcut{
Service: "sheets",
Command: "+batch-update",
Description: "Execute a batch of write shortcuts as a single atomic request (rolls back on failure by default).",
Description: "Execute a batch of write shortcuts in one request; fail-fast on the first failing sub-op (already-applied sub-ops are NOT rolled back).",
Risk: "high-risk-write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
@@ -64,7 +69,11 @@ var BatchUpdate = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
input, _ := batchUpdateInput(runtime, token)
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
dr := invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
if batchNeedsDimInsertBeforeStyleWarning(runtime) {
dr.Set("warning_message", dimInsertBeforeStyleWarning)
}
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
@@ -75,6 +84,9 @@ var BatchUpdate = common.Shortcut{
if err != nil {
return err
}
if batchNeedsDimInsertBeforeStyleWarning(runtime) {
fmt.Fprintln(runtime.IO().ErrOut, dimInsertBeforeStyleWarning)
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
if err != nil {
return err
@@ -83,7 +95,8 @@ var BatchUpdate = common.Shortcut{
return nil
},
Tips: []string{
"Default is strict transaction — any sub-tool failure rolls the whole batch back. Pass --continue-on-error to keep partial successes.",
"high-risk-write: always pass --yes (or --dry-run to preview) — without it the call exits 10 asking for confirmation.",
"Execution is fail-fast, NOT transactional: on \"N succeeded, M failed\" the succeeded sub-ops stay applied (no rollback) — fix the failure and resend ONLY the operations from the first failed index onward; resending the whole batch re-applies the succeeded ones. Pass --continue-on-error to keep going past failures instead.",
"Each sub-op is {shortcut, input}. Do NOT pass input.operation (implied by shortcut name) or input.excel_id / input.url (set at the +batch-update top level).",
},
}
@@ -124,6 +137,46 @@ func batchUpdateInput(runtime *common.RuntimeContext, token string) (map[string]
return input, nil
}
// batchNeedsDimInsertBeforeStyleWarning reports whether any +dim-insert sub-op
// requests --inherit-style before at the first row/column, where the
// preceding-side style cannot be copied (no preceding row/column exists).
func batchNeedsDimInsertBeforeStyleWarning(runtime *common.RuntimeContext) bool {
rawOps, err := parseBatchOperationsFlag(runtime)
if err != nil {
return false
}
for _, raw := range rawOps {
op, ok := raw.(map[string]interface{})
if !ok {
continue
}
sc, _ := op["shortcut"].(string)
if sc != "+dim-insert" {
continue
}
input, _ := op["input"].(map[string]interface{})
isBefore := false
for _, key := range []string{"inherit-style", "inherit_style", "inheritStyle"} {
if v, _ := input[key].(string); strings.EqualFold(v, "before") {
isBefore = true
break
}
}
if !isBefore {
continue
}
posRaw, hasPos := input["position"]
if !hasPos {
continue
}
// Warn only at the first row/column (idx 0).
if _, idx, err := parseA1Position(strings.TrimSpace(fmt.Sprintf("%v", posRaw))); err == nil && idx == 0 {
return true
}
}
return false
}
// parseBatchOperationsFlag accepts --operations as either a JSON array (the
// operations list directly) or an envelope object { operations, continue_on_error }
// for back-compat with the legacy --data shape. Returns the operations array.
@@ -160,6 +213,11 @@ var CellsBatchSetStyle = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-batch-set-style"),
Tips: []string{
"DEPRECATED: superseded by +styles-put, whose one spec also covers merges, row/col sizes and freeze — prefer it for new work.",
`Example: lark-cli sheets +cells-batch-set-style --url <URL> --ranges '["Sheet1!A1:B2","汇总!C1:C9"]' --font-weight bold`,
"Every range carries its sheet-NAME prefix (Sheet1!A1:B2, not a sheet_id) — there is no --sheet-id / --sheet-name flag here.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
@@ -189,6 +247,10 @@ var CellsBatchSetStyle = common.Shortcut{
if err != nil {
return err
}
// Phase-1 deprecation (docs already point at +styles-put): keep the
// command working, steer new usage to the superset in-band.
fmt.Fprintln(runtime.IO().ErrOut,
"note: +cells-batch-set-style is superseded by +styles-put (one spec covers styles + merges + row/col sizes + freeze); prefer +styles-put for new work")
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
if err != nil {
return err

View File

@@ -58,6 +58,39 @@ func TestBatchUpdate_TranslatesShortcutToToolName(t *testing.T) {
}
}
func TestBatchUpdate_DimInsertInheritAfterCopiesFollowingStyle(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[
{"shortcut":"+dim-insert","input":{"sheet_id":"sh1","position":"D","count":1,"inherit_style":"after"}}
]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
if len(ops) != 1 {
t.Fatalf("operations length = %d, want 1", len(ops))
}
op := ops[0].(map[string]interface{})
if op["tool_name"] != "modify_sheet_structure" {
t.Fatalf("tool_name = %v, want modify_sheet_structure", op["tool_name"])
}
in, _ := op["input"].(map[string]interface{})
// inherit_style=after copies the following column's style via a plain
// before-insert at the same position (the backend anchors on the following
// column), so position stays D with side=before.
assertInputEquals(t, in, map[string]interface{}{
"excel_id": testToken,
"sheet_id": "sh1",
"operation": "insert",
"position": "D",
"count": float64(1),
"side": "before",
})
}
func TestBatchUpdate_HighRiskWriteRequiresYes(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{
@@ -405,6 +438,21 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
opsJSON: `[{"shortcut":"+cells-set","input":"not-an-object"}]`,
wantMatch: "'input' must be a JSON object",
},
{
name: "wrapped cell_styles structure",
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","cell_styles":{"background_color":"#EBF1F8"}}}]`,
wantMatch: "do not wrap in cell_styles",
},
{
name: "wrapped styles structure",
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","styles":{"font_weight":"bold"}}}]`,
wantMatch: "do not wrap in styles",
},
{
name: "wrapped cell_merges structure",
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","cell_merges":[{"range":"A1:B1"}]}}]`,
wantMatch: "do not wrap in cell_merges",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -420,6 +468,99 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
}
}
// TestBatchUpdate_FlattenedStyleKeysNotMistakenForWrapper guards the
// wrapped-structure rejection against overreach: the same style fields in
// their correct flattened form must translate cleanly — only the wrapper
// container keys (cell_styles / styles / cell_merges) are rejected.
func TestBatchUpdate_FlattenedStyleKeysNotMistakenForWrapper(t *testing.T) {
t.Parallel()
got, err := translateBatchOp(map[string]interface{}{
"shortcut": "+cells-set-style",
"input": map[string]interface{}{
"sheet_name": "s",
"range": "A1",
"background_color": "#EBF1F8",
"font_weight": "bold",
},
}, testToken, 0)
if err != nil {
t.Fatalf("flattened style keys must pass the wrapper check, got %v", err)
}
input := got["input"].(map[string]interface{})
cells := input["cells"].([][]interface{})
style := cells[0][0].(map[string]interface{})["cell_styles"].(map[string]interface{})
if style["background_color"] != "#EBF1F8" || style["font_weight"] != "bold" {
t.Fatalf("translated style = %#v", style)
}
}
// TestBatchUpdate_WrapperKeysDisjointFromSubOpFlags locks the static
// assumption wrappedSubOpInputKeys relies on: no shortcut registered in
// batchOpDispatch declares a flag named cell_styles / cell_merges / styles.
// If a future dispatch-table addition (e.g. +table-put) carries one of these
// flags, its legitimate input would be silently rejected by the wrapper
// check — this test turns that silent breakage into a build-time failure.
func TestBatchUpdate_WrapperKeysDisjointFromSubOpFlags(t *testing.T) {
t.Parallel()
wrapped := make(map[string]struct{}, len(wrappedSubOpInputKeys))
for _, k := range wrappedSubOpInputKeys {
wrapped[k] = struct{}{}
}
for shortcut := range batchOpDispatch {
for _, f := range flagsFor(shortcut) {
key := strings.ReplaceAll(f.Name, "-", "_")
if _, clash := wrapped[key]; clash {
t.Errorf("%s declares flag --%s which collides with wrappedSubOpInputKeys; "+
"exempt this shortcut from the wrapper check before adding it to batchOpDispatch",
shortcut, f.Name)
}
}
}
}
// TestBatchUpdate_AggregatesMultipleOpErrors pins op-level aggregation: when
// several operations are invalid, one reply names them all (numbered, with
// each op's own error) instead of failing on the first bad op only. A single
// bad op keeps the historical single-error message (no aggregate wrapper).
func TestBatchUpdate_AggregatesMultipleOpErrors(t *testing.T) {
t.Parallel()
t.Run("two bad ops reported together", func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[
{"shortcut":"+cells-set-magic","input":{}},
{"shortcut":"+cells-set","input":{"sheet_name":"s","range":"A1"}},
{"shortcut":"+cells-clear","input":{"sheet_name":"s","range":"A1"}}
]`,
"--yes", "--dry-run",
})
requireValidation(t, err, "2 of 3 operations failed validation")
for _, want := range []string{"1) ", "2) ", "operations[0]", "operations[1]"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("aggregated op error should contain %q, got %q", want, err.Error())
}
}
})
t.Run("single bad op keeps plain message", func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[
{"shortcut":"+cells-set-magic","input":{}},
{"shortcut":"+cells-clear","input":{"sheet_name":"s","range":"A1"}}
]`,
"--yes", "--dry-run",
})
requireValidation(t, err, "not allowed in +batch-update")
if strings.Contains(err.Error(), "operations failed validation") {
t.Errorf("single bad op must not get the aggregate wrapper, got %q", err.Error())
}
})
}
// 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.

View File

@@ -67,7 +67,7 @@ var CellsClear = common.Shortcut{
return nil
},
Tips: []string{
"high-risk-write — always preview with --dry-run; clear is not undoable.",
"high-risk-write — pass --yes to confirm (exit 10 without it), or preview with --dry-run first; clear is not undoable.",
"Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.",
},
}
@@ -266,9 +266,13 @@ var ColsResize = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cols-resize"),
Validate: validateViaResize("column"),
DryRun: resizeDryRun("column"),
Execute: resizeExecute("column"),
Tips: []string{
"Example: lark-cli sheets +cols-resize --url <URL> --sheet-name Sheet1 --range A:C --width 120",
`Different widths per column in one atomic call: --widths '{"A":80,"C:E":120}'. Widths are pixels (px ≈ chars × 8 + 16), not Excel character units.`,
},
Validate: validateViaResize("column"),
DryRun: resizeDryRun("column"),
Execute: resizeExecute("column"),
}
// resizeDryRun / resizeExecute route a resize shortcut through resizeToolCall

View File

@@ -69,8 +69,7 @@ var CellsGet = common.Shortcut{
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
return emitReadResult(runtime, out)
},
}
@@ -84,21 +83,28 @@ func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName str
if runtime.Bool("skip-hidden") {
input["skip_hidden"] = true
}
// Preserve omission so the tool can keep the legacy fallback where
// skip_filter inherits skip_hidden. An explicit false must still be sent.
if runtime.Changed("skip-filter") {
input["skip_filter"] = runtime.Bool("skip-filter")
}
// --cell-limit was removed from the CLI surface; --max-chars is the single
// read cap. Pin cell_limit very high so the tool's own default never binds
// before max_chars.
input["cell_limit"] = unboundedReadLimit
if n := runtime.Int("max-chars"); n > 0 {
if n, ok := maxCharsInput(runtime); ok {
input["max_chars"] = n
}
return input
}
// applyIncludeToCellsGet maps the fine-grained --include vocabulary to the
// tool's two coarse switches:
// tool's switches:
//
// - include_styles (bool) — toggled by "style" presence
// - value_render_option (enum) — "formula" → formula; otherwise omitted
// - include_truncation_info (bool) — toggled by "truncation" presence; makes
// the tool estimate and return per-cell isRowTruncated / isColTruncated
//
// "value", "comment", and "data_validation" are always returned by the tool
// per the schema; they have no dedicated knob today but are accepted in
@@ -119,6 +125,9 @@ func applyIncludeToCellsGet(input map[string]interface{}, include []string) {
if want["formula"] {
input["value_render_option"] = "formula"
}
if want["truncation"] {
input["include_truncation_info"] = true
}
}
// CsvGet wraps get_range_as_csv: pull one range as RFC 4180 CSV with optional
@@ -139,9 +148,6 @@ var CsvGet = common.Shortcut{
if _, _, err := resolveSheetSelector(runtime); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return sheetsValidationForFlag("range", "--range is required")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -165,16 +171,25 @@ var CsvGet = common.Shortcut{
if !runtime.Bool("include-row-prefix") {
out = stripRowPrefixFromCsvOutput(out)
}
runtime.Out(out, nil)
return nil
return emitReadResult(runtime, out)
},
}
// csvGetFullSheetRange is the range sent when --range is omitted: the tool
// requires one, but clips anything past the grid bounds and reports the clip
// in actual_range — so an over-wide whole-columns range reads the entire
// sheet in one call, with no workbook-info pre-flight. Eval traces show
// "read the whole sheet" as a recurring intent (--range was the single most
// missed required flag once the rest of the surface was fixed).
const csvGetFullSheetRange = "A:ZZZ"
func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
input := map[string]interface{}{"excel_id": token}
sheetSelectorForToolInput(input, sheetID, sheetName)
if r := strings.TrimSpace(runtime.Str("range")); r != "" {
input["range"] = r
} else {
input["range"] = csvGetFullSheetRange
}
if runtime.Bool("skip-hidden") {
input["skip_hidden"] = true
@@ -183,7 +198,7 @@ func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName strin
// read cap. Pin max_rows very high so the tool's own default never binds
// before max_chars.
input["max_rows"] = unboundedReadLimit
if n := runtime.Int("max-chars"); n > 0 {
if n, ok := maxCharsInput(runtime); ok {
input["max_chars"] = n
}
return input

View File

@@ -34,6 +34,41 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
"cell_limit": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap
},
},
{
name: "+cells-get skip filtered rows only",
sc: CellsGet,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--skip-filter"},
toolName: "get_cell_ranges",
wantInput: map[string]interface{}{
"skip_filter": true,
},
},
{
name: "+cells-get skip hidden but keep filtered rows",
sc: CellsGet,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--skip-hidden", "--skip-filter=false"},
toolName: "get_cell_ranges",
wantInput: map[string]interface{}{
"skip_hidden": true,
"skip_filter": false,
},
},
{
// --include truncation toggles include_truncation_info so the tool
// estimates and returns per-cell isRowTruncated / isColTruncated.
name: "+cells-get include=truncation",
sc: CellsGet,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--include", "truncation"},
toolName: "get_cell_ranges",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"ranges": []interface{}{"A1:B2"},
"include_styles": false,
"include_truncation_info": true,
"cell_limit": float64(unboundedReadLimit),
},
},
{
// Canonical form: --sheet-id + bare --range. Aligned with
// +cells-get / +csv-get; before the e2e BUG-019 fix this
@@ -74,6 +109,17 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
}
}
func TestCellsGet_OmitsSkipFilterWhenUnset(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, CellsGet, []string{
"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2",
})
got := decodeToolInput(t, body, "get_cell_ranges")
if _, ok := got["skip_filter"]; ok {
t.Fatalf("skip_filter must be omitted when --skip-filter is unset: %#v", got)
}
}
// TestDropdownGet_RequiresSheetSelector locks the +cells-get-style
// selector contract: at least one of --sheet-id / --sheet-name must be
// supplied. Before BUG-019 fix this shortcut required a "Sheet!A1"
@@ -92,7 +138,9 @@ func TestDropdownGet_RequiresSheetSelector(t *testing.T) {
// TestReadData_RequiresRange covers the trim-based --range guard on the
// single-range readers (--range "" slips past cobra's MarkFlagRequired but
// must still be rejected by Validate).
// must still be rejected by Validate). +csv-get is deliberately absent:
// its --range is optional — omitted/blank means a whole-sheet read (see
// TestCsvGet_RangeOptionalDefaultsToFullSheet).
func TestReadData_RequiresRange(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -100,7 +148,6 @@ func TestReadData_RequiresRange(t *testing.T) {
sc common.Shortcut
}{
{"+cells-get", CellsGet},
{"+csv-get", CsvGet},
{"+dropdown-get", DropdownGet},
}
for _, c := range cases {
@@ -114,6 +161,23 @@ func TestReadData_RequiresRange(t *testing.T) {
}
}
// TestCsvGet_RangeOptionalDefaultsToFullSheet pins the whole-sheet default:
// with --range omitted the request carries the over-wide clip range, so a
// full read needs no workbook-info pre-flight (eval: --range was the most
// missed required flag on +csv-get once the rest of the surface settled).
func TestCsvGet_RangeOptionalDefaultsToFullSheet(t *testing.T) {
t.Parallel()
stdout, _, err := runShortcutCapturingErr(t, CsvGet, []string{
"--url", testURL, "--sheet-id", testSheetID, "--dry-run",
})
if err != nil {
t.Fatalf("rangeless +csv-get must pass validation, got: %v", err)
}
if !strings.Contains(stdout, csvGetFullSheetRange) {
t.Fatalf("dry-run body should carry the full-sheet range %q, got %q", csvGetFullSheetRange, stdout)
}
}
// TestInfoTypeFromInclude exercises the fine-grained → coarse mapping
// directly (white-box).
func TestInfoTypeFromInclude(t *testing.T) {

View File

@@ -6,6 +6,7 @@ package sheets
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
@@ -128,12 +129,20 @@ var DimInsert = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-insert"),
Validate: validateViaInput(dimInsertInput),
Tips: []string{
"Example: lark-cli sheets +dim-insert --url <URL> --sheet-name Sheet1 --position 3 --count 2 --inherit-style before",
"Rows vs columns comes from --position alone: a row number (3) inserts rows, a column letter (C) inserts columns — there is no --dimension flag.",
},
Validate: validateViaInput(dimInsertInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := dimInsertInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
dr := invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
if dimInsertNeedsBeforeStyleWarning(runtime) {
dr.Set("warning_message", dimInsertBeforeStyleWarning)
}
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
@@ -148,6 +157,9 @@ var DimInsert = common.Shortcut{
if err != nil {
return err
}
if dimInsertNeedsBeforeStyleWarning(runtime) {
fmt.Fprintln(runtime.IO().ErrOut, dimInsertBeforeStyleWarning)
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
if err != nil {
return err
@@ -157,8 +169,31 @@ var DimInsert = common.Shortcut{
},
}
// dimInsertBeforeStyleWarning fires only when the preceding-side style cannot
// be copied: --inherit-style before at the first row/column, where no
// preceding row/column exists. The row/column is still inserted before
// --position, just without style inheritance. (--inherit-style after has no
// such edge — a plain before-insert always has a following row/column.)
const dimInsertBeforeStyleWarning = "warning: --inherit-style before cannot copy the preceding row/column's style at the first row/column (no preceding row/column exists); inserting before --position without style inheritance. Copy styles separately if needed."
func dimInsertNeedsBeforeStyleWarning(runtime flagView) bool {
if !runtime.Changed("inherit-style") || runtime.Str("inherit-style") != "before" {
return false
}
// Only the first row/column (idx 0) has no preceding row/column.
_, idx, err := parseA1Position(strings.TrimSpace(runtime.Str("position")))
return err == nil && idx == 0
}
// dimInsertInput passes --position (1-based row number "3" or column letter
// "C") straight to the tool's `position` field; --count maps to `count`.
// "C") to the tool's `position` field; --count maps to `count`.
//
// +dim-insert's public contract is always "insert before --position";
// --inherit-style only selects which side's style the new row/column copies,
// never the insertion side. The sheet-ai tool always copies the *anchor*
// column's style (the target passed as position), regardless of side — so
// --inherit-style before is emulated by anchoring one unit earlier. See the
// switch below.
func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
@@ -184,11 +219,27 @@ func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[str
"count": count,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
// --inherit-style selects which side's style the blank row/column copies;
// the insertion always lands *before* --position. Empirically the addCol
// backend copies the *anchor* column's style (the target passed as
// position), regardless of side — side only decides whether the blank lands
// before or after that anchor (verified live, see
// TestDimInsertInheritStyleSideMapping):
// after → side=before at P: the blank lands at P and anchor P becomes the
// *following* neighbour, so the blank copies it. Position unchanged.
// before → side=after at P-1: the blank still lands at P (insert-after-(P-1)
// == insert-before-P) and anchor P-1 becomes the *preceding*
// neighbour, so the blank copies it.
switch runtime.Str("inherit-style") {
case "before":
input["side"] = "before"
case "after":
input["side"] = "after"
input["side"] = "before"
case "before":
if prev, ok := a1PositionBefore(position); ok {
input["side"] = "after"
input["position"] = prev
}
// First row/column: no preceding row/column exists, so fall back to a
// plain before-insert (dimInsertNeedsBeforeStyleWarning surfaces this).
}
return input, nil
}
@@ -203,10 +254,34 @@ var DimDelete = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-delete"),
Validate: validateDimRangeOp("delete"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Changed("ranges") {
if runtime.Changed("range") {
return sheetsValidationForFlag("ranges", "--range and --ranges are mutually exclusive; put every range into --ranges")
}
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
_, err = dimDeleteRangesOps(runtime, token, sheetID, sheetName)
return err
}
return validateDimRangeOp("delete")(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
if runtime.Changed("ranges") {
ops, _ := dimDeleteRangesOps(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
"excel_id": token,
"operations": ops,
})
}
input, _ := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
},
@@ -219,6 +294,21 @@ var DimDelete = common.Shortcut{
if err != nil {
return err
}
if runtime.Changed("ranges") {
ops, err := dimDeleteRangesOps(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
"excel_id": token,
"operations": ops,
})
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
}
input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
if err != nil {
return err
@@ -232,9 +322,76 @@ var DimDelete = common.Shortcut{
},
Tips: []string{
"Row/column deletion is irreversible. Always preview with --dry-run first.",
`Scattered ranges: --ranges '["5:5","8:8","11:13"]' deletes them in one atomic call — the CLI orders positions descending, so indexes never shift under you.`,
},
}
// dimDeleteRangesOps parses --ranges into one atomic batch of
// modify_sheet_structure delete ops, ordered DESCENDING by start position:
// deleting an earlier row shifts every later index up, so ascending
// execution deletes the wrong rows — the recurring failure of hand-built
// dim-delete batches in eval traces. Same-dimension and non-overlap are
// enforced up front.
func dimDeleteRangesOps(runtime flagView, token, sheetID, sheetName string) ([]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
raw, err := requireJSONArray(runtime, "ranges")
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, sheetsValidationForFlag("ranges", "--ranges must be a non-empty JSON array")
}
if len(raw) > maxBatchRanges {
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(raw))
}
type span struct {
raw string
start, end int
}
spans := make([]span, 0, len(raw))
dimension := ""
for i, v := range raw {
s, ok := v.(string)
if !ok {
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] must be a string", i)
}
dim, start, end, err := parseA1Range(s)
if err != nil {
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] %q: %v", i, s, err)
}
if dimension == "" {
dimension = dim
} else if dim != dimension {
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] %q is a %s range but earlier entries are %s ranges; one call deletes rows OR columns, not both", i, s, dim, dimension)
}
spans = append(spans, span{raw: strings.TrimSpace(s), start: start, end: end})
}
sort.Slice(spans, func(i, j int) bool { return spans[i].start > spans[j].start })
for i := 1; i < len(spans); i++ {
// Descending order: spans[i-1] starts at or after spans[i]. Overlap
// (or duplicate) makes the later delete hit already-shifted positions.
if spans[i].end >= spans[i-1].start {
return nil, sheetsValidationForFlag("ranges", "--ranges entries %q and %q overlap; merge them into one range", spans[i].raw, spans[i-1].raw)
}
}
ops := make([]interface{}, 0, len(spans))
for _, sp := range spans {
input := map[string]interface{}{
"excel_id": token,
"operation": "delete",
"range": sp.raw,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
ops = append(ops, map[string]interface{}{
"tool_name": "modify_sheet_structure",
"input": input,
})
}
return ops, nil
}
// validateDimRangeOp returns a Validate closure that delegates to
// dimRangeOpInput for shortcuts (delete/hide/unhide) whose builder takes an
// extra `op` argument. Token check happens here; the rest is the builder.
@@ -292,7 +449,10 @@ var DimFreeze = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-freeze"),
Validate: validateViaInput(dimFreezeInput),
Tips: []string{
"Example: lark-cli sheets +dim-freeze --url <URL> --sheet-name Sheet1 --dimension row --count 2 (freezes the first 2 rows; --count 0 unfreezes)",
},
Validate: validateViaInput(dimFreezeInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
@@ -557,6 +717,23 @@ func columnIndexToLetter(idx int) string {
return string(out)
}
// a1PositionBefore returns the A1 position one unit before s ("6" → "5",
// "C" → "B"), preserving row/column form. ok is false when s is the first
// row/column (row 1 / column A) — no earlier position — or is not a valid A1
// position. Callers validate via parseA1Position first, so in practice ok is
// false only at the first row/column.
func a1PositionBefore(s string) (pos string, ok bool) {
dimension, idx, err := parseA1Position(s)
if err != nil || idx == 0 {
return "", false
}
if dimension == "row" {
// idx is 0-based; the 1-based number one row earlier is idx itself.
return strconv.Itoa(idx), true
}
return columnIndexToLetter(idx - 1), true
}
// ─── +dim-move (native v3 move_dimension, cli_status: cli-only) ──────
//
// Moves a contiguous block of rows or columns to a new index in the same

View File

@@ -48,6 +48,8 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
},
},
{
// --inherit-style before copies the preceding row: anchor row 5 and
// insert after it (side=after), so the blank still lands before row 6.
name: "+dim-insert row position=6 count=3 inherit-before",
sc: DimInsert,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "6", "--count", "3", "--inherit-style", "before"},
@@ -56,9 +58,9 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
"excel_id": testToken,
"operation": "insert",
"sheet_id": testSheetID,
"position": "6",
"position": "5",
"count": float64(3),
"side": "before",
"side": "after",
},
},
{
@@ -169,6 +171,93 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
}
}
func TestDimInsertInheritStyleSideMapping(t *testing.T) {
t.Parallel()
cases := []struct {
name string
position string
inherit string
wantPosition string
wantSide string
wantSideSet bool
}{
{
name: "after copies the following style with a plain before-insert, position unchanged",
position: "D",
inherit: "after",
wantPosition: "D",
wantSide: "before",
wantSideSet: true,
},
{
name: "before anchors one column earlier (side=after) to copy the preceding style",
position: "D",
inherit: "before",
wantPosition: "C",
wantSide: "after",
wantSideSet: true,
},
{
name: "before on a row anchors one row earlier",
position: "6",
inherit: "before",
wantPosition: "5",
wantSide: "after",
wantSideSet: true,
},
{
name: "before at the first column falls back to a plain before-insert",
position: "A",
inherit: "before",
wantPosition: "A",
wantSideSet: false,
},
{
name: "after at the first column still works (before-insert anchors the following)",
position: "A",
inherit: "after",
wantPosition: "A",
wantSide: "before",
wantSideSet: true,
},
{
name: "default (flag omitted) omits side, backend inherits the following row/column",
position: "D",
wantPosition: "D",
wantSideSet: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
args := []string{"--url", testURL, "--sheet-id", testSheetID, "--position", tc.position, "--count", "1"}
if tc.inherit != "" {
args = append(args, "--inherit-style", tc.inherit)
}
body := parseDryRunBody(t, DimInsert, args)
got := decodeToolInput(t, body, "modify_sheet_structure")
assertInputEquals(t, got, map[string]interface{}{
"excel_id": testToken,
"operation": "insert",
"sheet_id": testSheetID,
"position": tc.wantPosition,
"count": float64(1),
})
gv, ok := got["side"]
if ok != tc.wantSideSet {
t.Fatalf("side presence = %v, want %v (input=%#v)", ok, tc.wantSideSet, got)
}
if ok && gv != tc.wantSide {
t.Fatalf("side = %v, want %q", gv, tc.wantSide)
}
})
}
}
// TestDimRange_Validation covers the A1 range parser's edge cases routed
// through +dim-hide (any --range shortcut works; we just need to exercise
// the validator).

View File

@@ -0,0 +1,272 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── +styles-put ──────────────────────────────────────────────────────
//
// Declarative visual spec for EXISTING spreadsheets. Eval attribution
// showed ~73% of real +batch-update calls were pure formatting finishers
// (style stamps + merges + resizes + freeze) hand-assembled as imperative
// operations arrays — the top error surface. +styles-put replaces that
// with the {styles:[...]} protocol already shared by +workbook-create /
// +table-put --styles (identical vocabulary, parsed by the same
// parseWorkbookCreateStyleItem), applied to a live workbook and expanded
// client-side into ONE atomic batch_update.
//
// Per-sheet expansion order (server behavior verified live: style stamps
// over merged regions are allowed — the top-left-only restriction applies
// to value writes, not styles):
//
// cell_merges → cell_styles → row_sizes → col_sizes → freeze
var StylesPut = common.Shortcut{
Service: "sheets",
Command: "+styles-put",
Description: "Apply one declarative visual spec (styles/merges/row-col sizes/freeze) to existing sheets in one atomic batch.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+styles-put"),
Tips: []string{
`Example: lark-cli sheets +styles-put --url <URL> --styles '{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:F1","font_weight":"bold"}],"freeze":{"rows":1}}]}'`,
"Same --styles vocabulary as +workbook-create / +table-put; one item per target sheet, name = the real sheet name.",
"Style stamps are safe to re-run; the whole spec goes out as one atomic batch.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
_, err = stylesPutOperations(runtime, token)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
ops, _ := stylesPutOperations(runtime, token)
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
"excel_id": token,
"operations": ops,
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
ops, err := stylesPutOperations(runtime, token)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
"excel_id": token,
"operations": ops,
})
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// stylesPutOperations parses --styles ({styles:[...]}, one item per target
// sheet) and expands it into the MCP batch_update operations array. Reuses
// the shared workbook-create style item parser, so field validation, alias
// normalization (border "all" shorthand, style vocabulary) and the
// aggregate-all-issues error shape are identical across the three --styles
// carriers.
func stylesPutOperations(runtime flagView, token string) ([]interface{}, error) {
if strings.TrimSpace(runtime.Str("styles")) == "" {
return nil, sheetsValidationForFlag("styles", "--styles is required")
}
v, err := parseJSONFlag(runtime, "styles")
if err != nil {
return nil, err
}
items, err := parseWorkbookCreateStylesItems(v)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, sheetsValidationForFlag("styles", "--styles.styles must be a non-empty array (one item per target sheet)")
}
var probs []error
type sheetSpec struct {
name string
payload *workbookCreateStylePayload
}
specs := make([]sheetSpec, 0, len(items))
seenName := map[string]bool{}
for i, item := range items {
path := fmt.Sprintf("--styles.styles[%d]", i)
name, _ := item["name"].(string)
name = strings.TrimSpace(name)
if name == "" {
probs = append(probs, common.ValidationErrorf("%s.name is required (the real sheet name; check +workbook-info)", path))
continue
}
if seenName[name] {
probs = append(probs, common.ValidationErrorf("%s.name %q appears twice; merge the two items", path, name))
continue
}
seenName[name] = true
payload, itemProbs := parseWorkbookCreateStyleItem(item, path)
if len(itemProbs) > 0 {
probs = append(probs, itemProbs...)
continue
}
specs = append(specs, sheetSpec{name: name, payload: payload})
}
if err := joinStyleValidationErrors(probs); err != nil {
return nil, err
}
ops := make([]interface{}, 0, len(specs)*4)
var totalCells int64
appendVisual := func(name string, op workbookCreateStyleOp) {
input, toolName := workbookCreateVisualOpInput(token, "", name, op)
if toolName == "" {
return
}
ops = append(ops, map[string]interface{}{"tool_name": toolName, "input": input})
}
for _, spec := range specs {
// merges first so subsequent style stamps see the final grid.
for _, m := range spec.payload.CellMerges {
appendVisual(spec.name, workbookCreateStyleOp{Kind: "cell_merge", Range: m.Range, MergeType: m.MergeType})
}
for _, cs := range coalesceStyleStamps(spec.payload.CellStyles) {
rows, cols, err := rangeDimensions(cs.Range)
if err != nil {
return nil, sheetsValidationForFlag("styles", "cell_styles range %q: %v", cs.Range, err)
}
if err := checkStampMatrixBudget("styles", cs.Range, rows, cols); err != nil {
return nil, err
}
totalCells += int64(rows) * int64(cols)
if err := checkBatchStampBudget(totalCells); err != nil {
return nil, err
}
ops = append(ops, map[string]interface{}{
"tool_name": "set_cell_range",
"input": map[string]interface{}{
"excel_id": token,
"sheet_name": spec.name,
"range": stripSheetPrefix(cs.Range),
"cells": fillCellsMatrix(rows, cols, cs.Style),
},
})
}
for _, rs := range spec.payload.RowSizes {
appendVisual(spec.name, workbookCreateStyleOp{Kind: "row_size", Range: rs.Range, ResizeType: rs.ResizeType, Size: rs.Size})
}
for _, csz := range spec.payload.ColSizes {
appendVisual(spec.name, workbookCreateStyleOp{Kind: "col_size", Range: csz.Range, ResizeType: csz.ResizeType, Size: csz.Size})
}
if f := spec.payload.Freeze; f != nil {
if f.Rows > 0 {
appendVisual(spec.name, workbookCreateStyleOp{Kind: "freeze_rows", Size: f.Rows})
}
if f.Cols > 0 {
appendVisual(spec.name, workbookCreateStyleOp{Kind: "freeze_cols", Size: f.Cols})
}
}
}
if len(ops) > maxBatchOperations {
return nil, sheetsValidationForFlag("styles",
"--styles expands to %d operations even after merging adjacent same-style ranges, over the %d cap; split the spec into several +styles-put calls — and for alternating-row banding or value-dependent coloring use +cond-format-create instead of per-row stamps",
len(ops), maxBatchOperations)
}
return ops, nil
}
// coalesceStyleStamps merges cell_styles entries that carry the IDENTICAL
// style into larger rectangles: same column span + contiguous/overlapping
// rows fuse vertically, same row span + contiguous columns fuse
// horizontally, iterated to a fixpoint. Models routinely emit one entry per
// row (07-21 rerun: specs expanding to 184/203/861 operations against the
// 100-op cap); a declarative spec describes intent, so execution shape is
// the CLI's to optimize. Entries with unparsable ranges pass through
// untouched (the per-op validation reports them with proper context).
func coalesceStyleStamps(ops []workbookCreateCellStyleOp) []workbookCreateCellStyleOp {
if len(ops) < 2 {
return ops
}
type rect struct{ c1, r1, c2, r2 int }
type group struct {
style map[string]interface{}
rects []rect
}
var order []string
groups := map[string]*group{}
out := make([]workbookCreateCellStyleOp, 0, len(ops))
for _, op := range ops {
c1, r1, c2, r2, err := workbookCreateStyleRangeBounds(op.Range)
key, jerr := json.Marshal(op.Style) // map keys marshal sorted → canonical
if err != nil || jerr != nil {
out = append(out, op)
continue
}
g, ok := groups[string(key)]
if !ok {
g = &group{style: op.Style}
groups[string(key)] = g
order = append(order, string(key))
}
g.rects = append(g.rects, rect{c1, r1, c2, r2})
}
for _, key := range order {
g := groups[key]
rects := g.rects
for changed := true; changed; {
changed = false
for i := 0; i < len(rects) && !changed; i++ {
for j := i + 1; j < len(rects); j++ {
a, b := rects[i], rects[j]
var merged rect
switch {
case a.c1 == b.c1 && a.c2 == b.c2 && b.r1 <= a.r2+1 && a.r1 <= b.r2+1:
merged = rect{a.c1, min(a.r1, b.r1), a.c2, max(a.r2, b.r2)}
case a.r1 == b.r1 && a.r2 == b.r2 && b.c1 <= a.c2+1 && a.c1 <= b.c2+1:
merged = rect{min(a.c1, b.c1), a.r1, max(a.c2, b.c2), a.r2}
default:
continue
}
rects[i] = merged
rects = append(rects[:j], rects[j+1:]...)
changed = true
break
}
}
}
for _, rc := range rects {
out = append(out, workbookCreateCellStyleOp{
Range: fmt.Sprintf("%s%d:%s%d",
columnIndexToLetter(rc.c1), rc.r1+1,
columnIndexToLetter(rc.c2), rc.r2+1),
Style: g.style,
})
}
}
return out
}
// stripSheetPrefix drops an optional "Sheet!"-style prefix from an A1 range:
// the target sheet is already carried by the spec item's name, and the
// batch sub-op input names the sheet separately.
func stripSheetPrefix(rangeStr string) string {
if idx := strings.Index(rangeStr, "!"); idx >= 0 {
return strings.TrimSpace(rangeStr[idx+1:])
}
return strings.TrimSpace(rangeStr)
}

View File

@@ -0,0 +1,347 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
func stylesPutView(spec map[string]interface{}) mapFlagView {
return newMapFlagViewForCommand("+styles-put", map[string]interface{}{"styles": spec})
}
// TestStylesPutOperations_ExpansionOrder pins the per-sheet expansion:
// cell_merges → cell_styles → row_sizes → col_sizes → freeze, all inside one
// batch_update operations array (server-side order dependence verified live).
func TestStylesPutOperations_ExpansionOrder(t *testing.T) {
t.Parallel()
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "Sheet1",
"cell_merges": []interface{}{map[string]interface{}{"range": "A5:A8"}},
"cell_styles": []interface{}{map[string]interface{}{"range": "A1:B1", "font_weight": "bold"}},
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "size": float64(36)}},
"col_sizes": []interface{}{map[string]interface{}{"range": "A:B", "type": "pixel", "size": float64(120)}},
"freeze": map[string]interface{}{"rows": float64(1), "cols": float64(2)},
}},
}), testToken)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wantTools := []string{"merge_cells", "set_cell_range", "resize_range", "resize_range", "modify_sheet_structure", "modify_sheet_structure"}
if len(ops) != len(wantTools) {
t.Fatalf("got %d ops, want %d", len(ops), len(wantTools))
}
for i, want := range wantTools {
op := ops[i].(map[string]interface{})
if op["tool_name"] != want {
t.Fatalf("ops[%d].tool_name = %v, want %s", i, op["tool_name"], want)
}
input := op["input"].(map[string]interface{})
if input["sheet_name"] != "Sheet1" {
t.Fatalf("ops[%d] missing sheet_name: %v", i, input)
}
if input["excel_id"] != testToken {
t.Fatalf("ops[%d] missing excel_id", i)
}
}
// The style stamp carries a cells matrix matching the range (1×2).
stamp := ops[1].(map[string]interface{})["input"].(map[string]interface{})
cells := stamp["cells"].([][]interface{})
if len(cells) != 1 || len(cells[0]) != 2 {
t.Fatalf("style stamp matrix = %dx%d, want 1x2", len(cells), len(cells[0]))
}
// Freeze ops carry the freeze counts.
fr := ops[4].(map[string]interface{})["input"].(map[string]interface{})
if fr["operation"] != "freeze" || fr["freeze_rows"] != 1 {
t.Fatalf("freeze rows op = %v", fr)
}
fc := ops[5].(map[string]interface{})["input"].(map[string]interface{})
if fc["freeze_columns"] != 2 {
t.Fatalf("freeze cols op = %v", fc)
}
}
// TestStylesPutOperations_Validation pins the aggregate error shape and the
// section/name requirements.
func TestStylesPutOperations_Validation(t *testing.T) {
t.Parallel()
t.Run("missing name and empty item aggregate", func(t *testing.T) {
t.Parallel()
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{
map[string]interface{}{"cell_styles": []interface{}{map[string]interface{}{"range": "A1", "font_weight": "bold"}}},
map[string]interface{}{"name": "S2"},
},
}), testToken)
ve := requireValidation(t, err, "name is required")
if !strings.Contains(ve.Message, "at least one of cell_styles/row_sizes/col_sizes/cell_merges/freeze") {
t.Fatalf("message %q missing empty-item issue", ve.Message)
}
})
t.Run("duplicate sheet name rejected", func(t *testing.T) {
t.Parallel()
item := map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(1)}}
item2 := map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(2)}}
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{item, item2},
}), testToken)
requireValidation(t, err, "appears twice")
})
t.Run("freeze-only item is valid", func(t *testing.T) {
t.Parallel()
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(1)}}},
}), testToken)
if err != nil || len(ops) != 1 {
t.Fatalf("ops=%d err=%v", len(ops), err)
}
})
t.Run("all-zero freeze rejected", func(t *testing.T) {
t.Parallel()
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(0)}}},
}), testToken)
requireValidation(t, err, "at least one dimension")
})
}
// TestStylesPayloadVocabularyForgiveness pins the 07-20 rerun fixes: the
// payload path (--styles cell_styles objects) accepts the same habitual
// vocabulary the flag path already normalized — border family folding, wrap
// aliases, and enum VALUE canonicalization (CSS center → Lark middle etc.).
func TestStylesPayloadVocabularyForgiveness(t *testing.T) {
t.Parallel()
stamp := func(styleFields map[string]interface{}) ([]interface{}, error) {
item := map[string]interface{}{"range": "A1:B1"}
for k, v := range styleFields {
item[k] = v
}
return stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "S1",
"cell_styles": []interface{}{item},
}},
}), testToken)
}
cellProto := func(t *testing.T, ops []interface{}) map[string]interface{} {
t.Helper()
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
cells := input["cells"].([][]interface{})
return cells[0][0].(map[string]interface{})
}
t.Run("vertical_alignment center canonicalizes to middle", func(t *testing.T) {
t.Parallel()
ops, err := stamp(map[string]interface{}{"vertical_alignment": "center", "font_weight": "BOLD"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cs := cellProto(t, ops)["cell_styles"].(map[string]interface{})
if cs["vertical_alignment"] != "middle" || cs["font_weight"] != "bold" {
t.Fatalf("cell_styles = %v, want middle/bold", cs)
}
})
t.Run("off-enum value rejected client-side with did-you-mean", func(t *testing.T) {
t.Parallel()
_, err := stamp(map[string]interface{}{"vertical_alignment": "botom"})
requireValidation(t, err, `did you mean "bottom"`)
})
t.Run("boolean wrap_text folds to word_wrap auto-wrap", func(t *testing.T) {
t.Parallel()
ops, err := stamp(map[string]interface{}{"wrap_text": true})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cs := cellProto(t, ops)["cell_styles"].(map[string]interface{})
if cs["word_wrap"] != "auto-wrap" {
t.Fatalf("word_wrap = %v, want auto-wrap", cs["word_wrap"])
}
})
t.Run("borders object folds into border_styles", func(t *testing.T) {
t.Parallel()
ops, err := stamp(map[string]interface{}{
"borders": map[string]interface{}{"style": "solid", "color": "#000000"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
top, _ := bs["top"].(map[string]interface{})
if top == nil || top["style"] != "solid" {
t.Fatalf("border_styles = %v, want all-sides solid", bs)
}
})
t.Run("flattened border_bottom and border_top_color fold per side", func(t *testing.T) {
t.Parallel()
ops, err := stamp(map[string]interface{}{
"border_bottom": map[string]interface{}{"style": "solid"},
"border_top_color": "#FF0000",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
bottom, _ := bs["bottom"].(map[string]interface{})
topSide, _ := bs["top"].(map[string]interface{})
if bottom["style"] != "solid" || topSide["color"] != "#FF0000" {
t.Fatalf("border_styles = %v", bs)
}
})
t.Run("border_style thin means thin solid line", func(t *testing.T) {
t.Parallel()
ops, err := stamp(map[string]interface{}{"border_style": "thin"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
top, _ := bs["top"].(map[string]interface{})
if top["weight"] != "thin" || top["style"] != "solid" {
t.Fatalf("border_styles.top = %v, want thin solid", top)
}
})
t.Run("fore_color prescribes instead of guessing", func(t *testing.T) {
t.Parallel()
_, err := stamp(map[string]interface{}{"fore_color": "#FF0000"})
requireValidation(t, err, "fore_color is ambiguous")
})
t.Run("bare string cell_merges accepted", func(t *testing.T) {
t.Parallel()
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "S1",
"cell_merges": []interface{}{"A5:B6"},
}},
}), testToken)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
if input["range"] != "A5:B6" || input["merge_type"] != "all" {
t.Fatalf("merge op = %v", input)
}
})
}
// TestStylesResizeSizeAliases pins the one-way Excel-vocabulary aliases on
// the shared styles resize parser: height in row_sizes / width in col_sizes
// resolve to size silently; the wrong dimension's word is a targeted error.
func TestStylesResizeSizeAliases(t *testing.T) {
t.Parallel()
t.Run("height aliases to size in row_sizes", func(t *testing.T) {
t.Parallel()
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "S1",
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "height": float64(36)}},
}},
}), testToken)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
block := input["resize_height"].(map[string]interface{})
if block["value"] != 36 {
t.Fatalf("resize_height = %v, want value 36", block)
}
})
t.Run("width aliases to size in col_sizes", func(t *testing.T) {
t.Parallel()
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "S1",
"col_sizes": []interface{}{map[string]interface{}{"range": "A:C", "type": "pixel", "width": float64(120)}},
}},
}), testToken)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("wrong-dimension word is a targeted error", func(t *testing.T) {
t.Parallel()
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "S1",
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "width": float64(36)}},
}},
}), testToken)
requireValidation(t, err, "does not apply to this array")
})
t.Run("size plus alias together rejected", func(t *testing.T) {
t.Parallel()
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "S1",
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "size": float64(36), "height": float64(40)}},
}},
}), testToken)
requireValidation(t, err, "either size or height")
})
}
// TestDimDeleteRangesOps pins the descending-order expansion and the
// same-dimension / non-overlap guards.
func TestDimDeleteRangesOps(t *testing.T) {
t.Parallel()
view := func(ranges ...interface{}) mapFlagView {
return newMapFlagViewForCommand("+dim-delete", map[string]interface{}{"ranges": ranges})
}
t.Run("rows execute descending", func(t *testing.T) {
t.Parallel()
ops, err := dimDeleteRangesOps(view("5:5", "11:13", "8:8"), testToken, "", "S1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got []string
for _, op := range ops {
got = append(got, op.(map[string]interface{})["input"].(map[string]interface{})["range"].(string))
}
want := []string{"11:13", "8:8", "5:5"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("order = %v, want %v", got, want)
}
}
})
t.Run("mixed dimensions rejected", func(t *testing.T) {
t.Parallel()
_, err := dimDeleteRangesOps(view("5:5", "C:C"), testToken, "", "S1")
requireValidation(t, err, "rows OR columns")
})
t.Run("overlap rejected", func(t *testing.T) {
t.Parallel()
_, err := dimDeleteRangesOps(view("5:8", "7:9"), testToken, "", "S1")
requireValidation(t, err, "overlap")
})
t.Run("ranges cannot nest inside batch", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+dim-delete", map[string]interface{}{
"sheet_name": "S1",
"ranges": []interface{}{"5:5", "8:8"},
}), testToken, 0)
requireValidation(t, err, "not supported inside +batch-update")
})
}

View File

@@ -88,6 +88,7 @@ var TablePut = common.Shortcut{
return tablePutWrite(ctx, runtime, token, payload, styles)
},
Tips: []string{
`Example: lark-cli sheets +table-put --url <URL> --sheets '{"sheets":[{"name":"S1","columns":["City","Rev"],"dtypes":{"Rev":"float64"},"data":[["SH",1234.5]]}]}'`,
"Writes into an existing spreadsheet — pass --url or --spreadsheet-token. To create a new workbook first, use +workbook-create, then point --spreadsheet-token here.",
"Payload sheets are matched to existing sub-sheets by name (created when absent). Date columns take ISO yyyy-mm-dd strings — converted to real dates (serial + date format).",
"--styles applies number formats, colors, merges, and row/col sizes in the same call (same shape as +workbook-create's --styles): one styles item per written sheet, name-matched. Skips the separate +cells-set-style round-trip.",
@@ -241,6 +242,11 @@ func decoderExpectEOF(dec *json.Decoder) error {
return nil
}
// tablePutSheetsSkeleton is the one-line --sheets shape inlined on a decode
// error, so the retry needs no --print-schema round trip. Field vocabulary
// mirrors tableSheetIn.
const tablePutSheetsSkeleton = `{"sheets":[{"name":"Sheet1","columns":["City","Revenue"],"dtypes":{"Revenue":"float64"},"data":[["SH",123.4],["BJ",56.7]],"start_cell":"A1"}]}`
// parseTablePutPayload reads --sheets (JSON, supports @file / stdin) into a
// validated payload. UseNumber keeps numeric cells as json.Number so large
// integers (order IDs, etc.) survive without precision loss or scientific
@@ -259,7 +265,19 @@ func parseTablePutPayload(runtime flagView) (*tablePayload, error) {
Sheets []tableSheetIn `json:"sheets"`
}
if err := dec.Decode(&wire); err != nil {
return nil, common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
// Eval traces show two distinct decode failures that each burned
// retries: a field with the wrong JSON kind (columns as objects,
// dtypes as an array) — fixed by seeing the expected shape once —
// and shell-mangled JSON, fixed by moving the payload to stdin/@file.
verr := common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
var ute *json.UnmarshalTypeError
if errors.As(err, &ute) {
return nil, verr.WithHint(
"expected shape: %s (columns is a flat string array; dtypes/formats are column-name-keyed maps; data is row-major)",
tablePutSheetsSkeleton)
}
return nil, verr.WithHint(
"if the payload contains formulas / quotes / commas, pass it via stdin (`--sheets - < file`) or a relative @file (`--sheets @./payload.json`)")
}
// Reject trailing non-whitespace after the first JSON value: json.Decoder
// accepts it silently (unlike json.Unmarshal), so e.g. `--sheets '{...} oops'`
@@ -1208,12 +1226,11 @@ var TableGet = common.Shortcut{
}
sheets = append(sheets, spec)
}
runtime.Out(map[string]interface{}{"sheets": sheets}, nil)
return nil
return emitReadResult(runtime, map[string]interface{}{"sheets": sheets})
},
Tips: []string{
"Output is the same shape +table-put consumes — pipe it back in, or load sheets[].rows into a DataFrame keyed by columns[].name.",
"Column types are inferred per column, but only when every non-empty cell agrees; a column mixing types (e.g. numbers + \"暂无\") degrades to string — lossless and round-trips cleanly. Numeric coercion of dirty cells is the caller's job (pandas to_numeric(errors=\"coerce\") on the string column).",
"Column types are inferred per column, but only when every non-empty cell agrees; a column mixing types (e.g. numbers + \"N/A\") degrades to string — lossless and round-trips cleanly. Numeric coercion of dirty cells is the caller's job (pandas to_numeric(errors=\"coerce\") on the string column).",
},
}
@@ -1354,11 +1371,18 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
"value_render_option": "raw_value",
"cell_limit": unboundedReadLimit,
}
// --max-chars binds the char budget (default 500000); --output-path lifts it
// to unbounded. Without this the tool applied its own ~50000 default and
// silently dropped rows past it with no signal in the +table-get output.
if n, ok := maxCharsInput(runtime); ok {
input["max_chars"] = n
}
sheetSelectorForToolInput(input, t.id, t.name)
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", input)
if err != nil {
return nil, err
}
truncated := cellRangesTruncated(out)
grid := extractCellGrid(out)
if len(grid) == 0 {
return emptySpec(), nil
@@ -1433,9 +1457,38 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
if len(formats) > 0 {
spec["formats"] = formats
}
// The tool clipped the read at max_chars: rows past the cap are missing from
// data. Surface it so the caller doesn't mistake a partial read for the whole
// sheet — re-run with --output-path (unlimited) or a higher --max-chars.
if truncated {
spec["truncated"] = true
spec["truncation_warning"] = "Result truncated by max_chars; rows past the cap were not returned. Best: re-run with --output-path to dump the whole sheet in one lossless pass (no cap). Alternatively raise --max-chars, or continue-read the remaining rows by passing --range for them — but that needs --no-header and you must reattach the header row and reconcile per-chunk dtypes yourself (this chunk's types were inferred from the rows returned here)."
}
return spec, nil
}
// cellRangesTruncated reports whether a get_cell_ranges response was clipped by
// max_chars — either the top-level has_more flag or the first range's truncated
// flag. Used by +table-get, whose spec output otherwise drops both signals.
func cellRangesTruncated(out interface{}) bool {
m, ok := out.(map[string]interface{})
if !ok {
return false
}
if hm, ok := m["has_more"].(bool); ok && hm {
return true
}
ranges, _ := m["ranges"].([]interface{})
if len(ranges) > 0 {
if r0, ok := ranges[0].(map[string]interface{}); ok {
if t, ok := r0["truncated"].(bool); ok {
return t
}
}
}
return false
}
// sheetCurrentRegion returns the A1 range covering the sheet's existing data,
// or "" for an empty sheet.
//
@@ -1522,7 +1575,7 @@ func readCellFormat(cell map[string]interface{}) string {
// inferColumnType decides a column's type from its data cells: a date
// number_format guides each cell's type, but a column is given a non-string type
// only when EVERY non-empty cell agrees. Real sheet columns often mix types (a
// number column with a stray "暂无", a date column with a bare count); declaring
// number column with a stray "N/A", a date column with a bare count); declaring
// number/date while a string value rides along makes the output inconsistent —
// it breaks round-trip back into +table-put (which rejects a string in a number
// column) and crashes pandas astype. So a mixed column degrades to string

View File

@@ -1140,7 +1140,7 @@ func TestTableGet_InferColumnType(t *testing.T) {
// Mixed number+text degrades to string (self-consistent: every value is then
// a string), so the column round-trips and pandas doesn't choke. Numeric
// coercion of the dirty cells is left to the caller (pandas to_numeric).
if typ, _ := inferColumnType(col(mk(100.0, ""), mk("暂无", ""), mk(200.0, "")), 0); typ != "string" {
if typ, _ := inferColumnType(col(mk(100.0, ""), mk("N/A", ""), mk(200.0, "")), 0); typ != "string" {
t.Errorf("mixed number+text col → %s, want string", typ)
}
// A bare number mixed into a date column must NOT stay date (would serial-

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/drive"
@@ -405,7 +406,11 @@ var SheetCopy = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+sheet-copy"),
Validate: validateViaInput(sheetCopyInput),
Tips: []string{
"Example: lark-cli sheets +sheet-copy --url <URL> --sheet-name 数据源 --title 数据源-副本",
"--sheet-name / --sheet-id selects the SOURCE sheet; the copy's new name goes in --title.",
},
Validate: validateViaInput(sheetCopyInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
@@ -914,6 +919,14 @@ type workbookCreateStylePayload struct {
RowSizes []workbookCreateResizeOp
ColSizes []workbookCreateResizeOp
CellMerges []workbookCreateMergeOp
Freeze *workbookCreateFreezeOp
}
// workbookCreateFreezeOp freezes the first Rows rows / Cols columns.
// Zero means "leave that dimension alone".
type workbookCreateFreezeOp struct {
Rows int
Cols int
}
type workbookCreateCellStyleOp struct {
@@ -965,7 +978,11 @@ func parseWorkbookCreateStyles(runtime flagView) (*workbookCreateStylePayload, e
if len(items) != 1 {
return nil, common.ValidationErrorf("--styles.styles must contain exactly one item when using --values")
}
return parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
payload, probs := parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
if err := joinStyleValidationErrors(probs); err != nil {
return nil, err
}
return payload, nil
}
// parseWorkbookCreateSheetStyles parses --styles for the typed --sheets path.
@@ -988,21 +1005,28 @@ func parseWorkbookCreateSheetStyles(runtime flagView, payload *tablePayload) (*w
}
out := &workbookCreateSheetStyles{ByName: map[string]*workbookCreateStylePayload{}}
out.ByIndex = make([]*workbookCreateStylePayload, len(payload.Sheets))
var probs []error
for i, item := range items {
name, _ := item["name"].(string)
if strings.TrimSpace(name) == "" {
return nil, common.ValidationErrorf("--styles.styles[%d].name is required", i)
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name is required", i))
continue
}
if name != payload.Sheets[i].Name {
return nil, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name)
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name))
continue
}
style, err := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
if err != nil {
return nil, err
style, itemProbs := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
if len(itemProbs) > 0 {
probs = append(probs, itemProbs...)
continue
}
out.ByIndex[i] = style
out.ByName[name] = style
}
if err := joinStyleValidationErrors(probs); err != nil {
return nil, err
}
return out, nil
}
@@ -1030,182 +1054,337 @@ func parseWorkbookCreateStylesItems(v interface{}) ([]map[string]interface{}, er
return items, nil
}
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, error) {
// parseWorkbookCreateStyleItem parses one --styles item. All four sections
// are validated even after one fails, and every issue is returned in the
// slice: eval traces show agents fixing --styles errors one round trip per
// error (border side, then row_sizes.type, then size…) because only the
// first was ever reported.
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, []error) {
payload := &workbookCreateStylePayload{}
var err error
var probs []error
if raw, ok := item["cell_styles"]; ok {
payload.CellStyles, err = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
if err != nil {
return nil, err
}
var errsHere []error
payload.CellStyles, errsHere = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
probs = append(probs, errsHere...)
}
if raw, ok := item["row_sizes"]; ok {
payload.RowSizes, err = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
if err != nil {
return nil, err
}
var errsHere []error
payload.RowSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
probs = append(probs, errsHere...)
}
if raw, ok := item["col_sizes"]; ok {
payload.ColSizes, err = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
if err != nil {
return nil, err
}
var errsHere []error
payload.ColSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
probs = append(probs, errsHere...)
}
if raw, ok := item["cell_merges"]; ok {
payload.CellMerges, err = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
var errsHere []error
payload.CellMerges, errsHere = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
probs = append(probs, errsHere...)
}
if raw, ok := item["freeze"]; ok {
freeze, err := parseWorkbookCreateFreezeOp(raw, path+".freeze")
if err != nil {
return nil, err
probs = append(probs, err)
} else {
payload.Freeze = freeze
}
}
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 {
return nil, common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges", path)
if len(probs) > 0 {
return nil, probs
}
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 && payload.Freeze == nil {
return nil, []error{common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges/freeze", path)}
}
return payload, nil
}
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, error) {
// parseWorkbookCreateFreezeOp parses a {rows, cols} freeze section. At least
// one dimension must be positive — an all-zero freeze is a no-op the caller
// almost certainly didn't mean.
func parseWorkbookCreateFreezeOp(raw interface{}, path string) (*workbookCreateFreezeOp, error) {
obj, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an object like {\"rows\":1} or {\"rows\":1,\"cols\":2}", path)
}
out := &workbookCreateFreezeOp{}
for k, v := range obj {
n, isNum := v.(float64)
if !isNum || n != float64(int(n)) || n < 0 {
return nil, common.ValidationErrorf("%s.%s must be a non-negative integer", path, k)
}
switch k {
case "rows":
out.Rows = int(n)
case "cols", "columns":
out.Cols = int(n)
default:
return nil, common.ValidationErrorf("%s.%s is not a supported field (want rows/cols)", path, k)
}
}
if out.Rows == 0 && out.Cols == 0 {
return nil, common.ValidationErrorf("%s must freeze at least one dimension (rows or cols > 0)", path)
}
return out, nil
}
// joinStyleValidationErrors folds the issues collected across one --styles
// parse into a single typed error that lists them all, so the caller can fix
// the whole payload in one retry instead of one error per round trip.
func joinStyleValidationErrors(probs []error) error {
switch len(probs) {
case 0:
return nil
case 1:
return probs[0]
}
const maxShown = 8
msgs := make([]string, 0, len(probs))
for _, e := range probs {
if p, ok := errs.ProblemOf(e); ok {
msgs = append(msgs, p.Message)
continue
}
msgs = append(msgs, e.Error())
}
suffix := ""
if len(msgs) > maxShown {
suffix = fmt.Sprintf(" (+%d more)", len(msgs)-maxShown)
msgs = msgs[:maxShown]
}
return common.ValidationErrorf("--styles has %d issues: %s%s", len(probs), strings.Join(msgs, " | "), suffix)
}
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, []error) {
arr, ok := v.([]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an array", path)
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
}
ops := make([]workbookCreateCellStyleOp, 0, len(arr))
var probs []error
for i, raw := range arr {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
}
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
op, err := parseWorkbookCreateCellStyleOp(raw, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
probs = append(probs, err)
continue
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
}
styleObj := make(map[string]interface{}, len(op)-1)
for k, v := range op {
if k == "range" {
continue
}
styleObj[k] = v
}
style, err := normalizeWorkbookCreateStyleObject(styleObj, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
}
if len(style) == 0 {
return nil, common.ValidationErrorf("%s[%d] must include at least one style field", path, i)
}
ops = append(ops, workbookCreateCellStyleOp{Range: rangeStr, Style: style})
ops = append(ops, op)
}
return ops, nil
return ops, probs
}
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, error) {
func parseWorkbookCreateCellStyleOp(raw interface{}, path string) (workbookCreateCellStyleOp, error) {
op, ok := raw.(map[string]interface{})
if !ok {
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must be an object", path)
}
rangeStr, err := requireWorkbookCreateRange(op, path)
if err != nil {
return workbookCreateCellStyleOp{}, err
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
}
styleObj := make(map[string]interface{}, len(op)-1)
for k, v := range op {
if k == "range" {
continue
}
styleObj[k] = v
}
style, err := normalizeWorkbookCreateStyleObject(styleObj, path)
if err != nil {
return workbookCreateCellStyleOp{}, err
}
if len(style) == 0 {
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must include at least one style field", path)
}
return workbookCreateCellStyleOp{Range: rangeStr, Style: style}, nil
}
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, []error) {
arr, ok := v.([]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an array", path)
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
}
ops := make([]workbookCreateMergeOp, 0, len(arr))
var probs []error
for i, raw := range arr {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
}
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
op, err := parseWorkbookCreateMergeOp(raw, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
probs = append(probs, err)
continue
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
}
mergeType := "all"
if raw, ok := op["merge_type"]; ok {
v, ok := raw.(string)
if !ok || strings.TrimSpace(v) == "" {
return nil, common.ValidationErrorf("%s[%d].merge_type must be a non-empty string", path, i)
}
mergeType = strings.TrimSpace(v)
}
switch mergeType {
case "all", "rows", "columns":
default:
return nil, common.ValidationErrorf("%s[%d].merge_type %q is invalid (want all/rows/columns)", path, i, mergeType)
}
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "merge_type"); err != nil {
return nil, err
}
ops = append(ops, workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType})
ops = append(ops, op)
}
return ops, nil
return ops, probs
}
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, error) {
func parseWorkbookCreateMergeOp(raw interface{}, path string) (workbookCreateMergeOp, error) {
// A bare range string means {range: s, merge_type: all} — the only
// possible reading (07-20 eval hit).
if s, ok := raw.(string); ok && strings.TrimSpace(s) != "" {
raw = map[string]interface{}{"range": strings.TrimSpace(s)}
}
op, ok := raw.(map[string]interface{})
if !ok {
return workbookCreateMergeOp{}, common.ValidationErrorf("%s must be an object", path)
}
rangeStr, err := requireWorkbookCreateRange(op, path)
if err != nil {
return workbookCreateMergeOp{}, err
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
}
mergeType := "all"
if raw, ok := op["merge_type"]; ok {
v, ok := raw.(string)
if !ok || strings.TrimSpace(v) == "" {
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type must be a non-empty string", path)
}
mergeType = normalizeMergeType(strings.TrimSpace(v))
}
switch mergeType {
case "all", "rows", "columns":
default:
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type %q is invalid (want all/rows/columns)", path, mergeType)
}
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "merge_type"); err != nil {
return workbookCreateMergeOp{}, err
}
return workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType}, nil
}
// normalizeMergeType maps the raw OpenAPI merge vocabulary (MERGE_ALL /
// MERGE_ROWS / MERGE_COLUMNS — which agents reproduce from the Lark API
// docs) onto the CLI's all/rows/columns. Unknown values pass through for
// the caller's enum check to reject.
func normalizeMergeType(v string) string {
lower := strings.ToLower(v)
lower = strings.TrimPrefix(lower, "merge_")
switch lower {
case "all", "rows", "columns":
return lower
}
return v
}
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, []error) {
arr, ok := v.([]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an array", path)
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
}
ops := make([]workbookCreateResizeOp, 0, len(arr))
var probs []error
for i, raw := range arr {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
}
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
op, err := parseWorkbookCreateResizeOp(raw, fmt.Sprintf("%s[%d]", path, i), dimension)
if err != nil {
return nil, err
probs = append(probs, err)
continue
}
parsedDim, _, _, err := parseA1Range(rangeStr)
if err != nil {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
return nil, common.ValidationErrorf("%s[%d].range %q must use %s: %v", path, i, rangeStr, want, err)
ops = append(ops, op)
}
return ops, probs
}
// resizeOpExample renders a complete valid op for the dimension, inlined on
// every type/size error: eval traces show the field errors chaining (type
// "custom" → fixed to pixel → "pixel requires size"), each costing a round
// trip, because no error ever showed a whole valid op at once.
func resizeOpExample(dimension string) string {
if dimension == "column" {
return `{"range":"A:C","type":"pixel","size":120} (or {"range":"A:C","type":"standard"} to reset)`
}
return `{"range":"2:10","type":"pixel","size":32} (or "type":"auto" to fit content)`
}
func parseWorkbookCreateResizeOp(raw interface{}, path, dimension string) (workbookCreateResizeOp, error) {
op, ok := raw.(map[string]interface{})
if !ok {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s must be an object", path)
}
rangeStr, err := requireWorkbookCreateRange(op, path)
if err != nil {
return workbookCreateResizeOp{}, err
}
parsedDim, _, _, err := parseA1Range(rangeStr)
if err != nil {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
if parsedDim != dimension {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
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 (%s)", path, i, typeHint)
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s: %v", path, rangeStr, want, err)
}
if parsedDim != dimension {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s", path, rangeStr, want)
}
typeHint := "pixel/standard"
if dimension == "row" {
typeHint = "pixel/standard/auto"
}
resizeType, _ := op["type"].(string)
resizeType = strings.TrimSpace(resizeType)
if resizeType != "" {
if dimension == "column" && resizeType == "auto" {
return nil, common.ValidationErrorf("%s[%d].type auto is rows-only", path, i)
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type auto is rows-only", path)
}
switch resizeType {
case "pixel", "standard", "auto":
default:
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want %s)", path, i, resizeType, typeHint)
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type %q is invalid (want %s), e.g. %s", path, resizeType, typeHint, resizeOpExample(dimension))
}
size := 0
if raw, ok := op["size"]; ok {
n, ok := util.ToFloat64(raw)
if !ok || n <= 0 {
return nil, common.ValidationErrorf("%s[%d].size must be a positive number", path, i)
}
size = int(n)
}
if resizeType == "pixel" && size <= 0 {
return nil, common.ValidationErrorf("%s[%d].type pixel requires size", path, i)
}
if resizeType != "pixel" && size > 0 {
return nil, common.ValidationErrorf("%s[%d].size is only valid with type pixel", path, i)
}
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "type", "size"); err != nil {
return nil, err
}
ops = append(ops, workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size})
}
return ops, nil
// size is the canonical dimension key (uniform across row_sizes and
// col_sizes — the array name already carries the dimension). The Excel-
// vocabulary alias (height on rows, width on columns) is accepted
// silently; the WRONG dimension's word is a targeted error, never a
// silent rewrite.
alias, wrongDim := "height", "width"
if dimension == "column" {
alias, wrongDim = "width", "height"
}
if _, has := op[wrongDim]; has {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.%s does not apply to this array (the array name carries the dimension); use size, e.g. %s", path, wrongDim, resizeOpExample(dimension))
}
sizeRaw, hasSize := op["size"]
if aliasRaw, hasAlias := op[alias]; hasAlias {
if hasSize {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s: give either size or %s, not both", path, alias)
}
sizeRaw, hasSize = aliasRaw, true
}
size := 0
if hasSize {
n, ok := util.ToFloat64(sizeRaw)
if !ok || n <= 0 {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size must be a positive number", path)
}
size = int(n)
}
// type is optional ceremony when a pixel size is given: {range, size}
// means a pixel resize, exactly as --width/--height without --type does
// on the flag path. Explicit standard/auto still needs type.
if resizeType == "" {
if size <= 0 {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s needs size (px) or type (%s), e.g. %s", path, typeHint, resizeOpExample(dimension))
}
resizeType = "pixel"
}
if resizeType == "pixel" && size <= 0 {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type pixel requires size, e.g. %s", path, resizeOpExample(dimension))
}
if resizeType != "pixel" && size > 0 {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size is only valid with type pixel", path)
}
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "type", "size", alias); err != nil {
return workbookCreateResizeOp{}, err
}
return workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size}, nil
}
func requireWorkbookCreateRange(op map[string]interface{}, path string) (string, error) {
@@ -1245,6 +1424,9 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
if len(in) == 0 {
return nil, nil
}
if err := foldBorderFamilyAliases(in, path); err != nil {
return nil, err
}
if err := normalizeCellStyleAliases(in, path); err != nil {
return nil, err
}
@@ -1259,15 +1441,26 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
if !ok {
return nil, common.ValidationErrorf("%s.border_styles must be a JSON object", path)
}
expandBorderAllShorthand(m)
if err := validateWorkbookBorderStyles(m, path); err != nil {
return nil, err
}
out["border_styles"] = m
case "value", "formula", "rich_text", "multiple_values", "note", "data_validation":
return nil, common.ValidationErrorf("%s is for styles only; put content in --values or use --sheets for typed cell objects", path)
return nil, common.ValidationErrorf("%s.%s is a content field — a styles spec carries no cell content; write values/formulas via +cells-set or +table-put", path, k)
default:
if !workbookCreateCellStyleField(k) {
return nil, common.ValidationErrorf("%s.%s is not a supported style field", path, k)
// Universal rejection with did-you-mean + the full field list:
// this is the mechanism that absorbs the infinite tail of
// spelling permutations at a fixed one-retry cost — silent
// aliases are reserved for high-frequency words from real
// external vocabularies (see the style_vocab.go contract).
msg := fmt.Sprintf("%s.%s is not a supported style field", path, k)
if match := suggest.Closest(strings.ToLower(k), workbookCreateCellStyleFieldList, 1); len(match) > 0 {
msg += fmt.Sprintf(" — did you mean %q?", match[0])
}
msg += "; supported: " + strings.Join(workbookCreateCellStyleFieldList, ", ")
return nil, common.ValidationErrorf("%s", msg)
}
cellStyle[k] = v
}
@@ -1278,6 +1471,14 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
return out, nil
}
// workbookCreateCellStyleFieldList is the canonical style vocabulary plus the
// two border carriers, in display order for the unknown-field hint.
var workbookCreateCellStyleFieldList = []string{
"font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
"background_color", "horizontal_alignment", "vertical_alignment",
"number_format", "word_wrap", "border", "border_styles",
}
func workbookCreateCellStyleField(name string) bool {
switch name {
case "font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
@@ -1299,7 +1500,7 @@ func validateWorkbookBorderStyles(m map[string]interface{}, path string) error {
switch side {
case "top", "bottom", "left", "right":
default:
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right)", path, side)
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right; a horizontal line is the top/bottom side of its range, a vertical line is left/right)", path, side)
}
spec, ok := raw.(map[string]interface{})
if !ok {
@@ -1516,7 +1717,7 @@ func workbookCreateVisualOps(styles *workbookCreateStylePayload) []workbookCreat
if styles == nil {
return nil
}
ops := make([]workbookCreateStyleOp, 0, len(styles.CellMerges)+len(styles.RowSizes)+len(styles.ColSizes))
ops := make([]workbookCreateStyleOp, 0, len(styles.CellMerges)+len(styles.RowSizes)+len(styles.ColSizes)+2)
for _, op := range styles.CellMerges {
ops = append(ops, workbookCreateStyleOp{Kind: "cell_merge", Range: op.Range, MergeType: op.MergeType})
}
@@ -1526,6 +1727,14 @@ func workbookCreateVisualOps(styles *workbookCreateStylePayload) []workbookCreat
for _, op := range styles.ColSizes {
ops = append(ops, workbookCreateStyleOp{Kind: "col_size", Range: op.Range, ResizeType: op.ResizeType, Size: op.Size})
}
if styles.Freeze != nil {
if styles.Freeze.Rows > 0 {
ops = append(ops, workbookCreateStyleOp{Kind: "freeze_rows", Size: styles.Freeze.Rows})
}
if styles.Freeze.Cols > 0 {
ops = append(ops, workbookCreateStyleOp{Kind: "freeze_cols", Size: styles.Freeze.Cols})
}
}
return ops
}
@@ -1564,6 +1773,18 @@ func workbookCreateVisualOpInput(token, sheetID, sheetName string, op workbookCr
input["resize_width"] = block
}
return input, "resize_range"
case "freeze_rows", "freeze_cols":
input := map[string]interface{}{
"excel_id": token,
"operation": "freeze",
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if op.Kind == "freeze_rows" {
input["freeze_rows"] = op.Size
} else {
input["freeze_columns"] = op.Size
}
return input, "modify_sheet_structure"
default:
return nil, ""
}

View File

@@ -38,7 +38,11 @@ import (
// CellsSet wraps set_cell_range: caller provides the cells matrix via --cells
// (JSON), with an optional --copy-to-range to replicate the written block
// across a larger area (formula refs auto-shift).
// across a larger area (formula refs auto-shift). The plural form --writes
// ([{sheet_name, range, cells}, …]) fans scattered regions — cross-sheet
// allowed — into ONE atomic batch_update: eval traces show "fix all broken
// formulas across ranges/sheets" as the dominant homogeneous scenario still
// hand-assembled as +batch-update operations arrays.
var CellsSet = common.Shortcut{
Service: "sheets",
Command: "+cells-set",
@@ -48,9 +52,31 @@ var CellsSet = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-set"),
Validate: validateViaInput(cellsSetInput),
Tips: []string{
`Example: lark-cli sheets +cells-set --url <URL> --sheet-name Sheet1 --range A1:B1 --cells '[[{"value":"名称"},{"formula":"=SUM(B2:B9)"}]]'`,
`--cells is always a 2D array (rows × cells), even for one cell: [[{"value":…}]].`,
`Scattered regions (e.g. fixing formulas across ranges/sheets): --writes '[{"sheet_name":…,"range":…,"cells":[[…]]}, …]' — one atomic call, sheet selector inside each item.`,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Changed("writes") {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
_, err = cellsSetWritesOps(runtime, token)
return err
}
return validateViaInput(cellsSetInput)(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
if runtime.Changed("writes") {
ops, _ := cellsSetWritesOps(runtime, token)
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
"excel_id": token,
"operations": ops,
})
}
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := cellsSetInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input)
@@ -60,6 +86,21 @@ var CellsSet = common.Shortcut{
if err != nil {
return err
}
if runtime.Changed("writes") {
ops, err := cellsSetWritesOps(runtime, token)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
"excel_id": token,
"operations": ops,
})
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
@@ -77,6 +118,108 @@ var CellsSet = common.Shortcut{
},
}
// cellsSetWritesOps parses --writes ([{sheet_name|sheet_id, range, cells}, …])
// and expands it into set_cell_range operations for ONE atomic batch_update.
// Single source of truth per item: the sheet selector LIVES IN THE ITEM (same
// convention as +batch-update sub-ops and +styles-put items — no top-level
// fallback, no precedence table to remember). Every item runs through the
// exact standalone pipeline (key vocabulary, style acceptance layer, matrix
// precheck, schema validation) via a per-item flag view, and item errors are
// aggregated so one retry fixes them all.
func cellsSetWritesOps(runtime *common.RuntimeContext, token string) ([]interface{}, error) {
for _, conflicting := range []string{"range", "cells", "copy-to-range"} {
if runtime.Changed(conflicting) {
return nil, sheetsValidationForFlag("writes", "--writes and --%s are mutually exclusive: single region → --range + --cells; multiple regions → --writes alone", conflicting)
}
}
if strings.TrimSpace(runtime.Str("sheet-name")) != "" || strings.TrimSpace(runtime.Str("sheet-id")) != "" {
return nil, sheetsValidationForFlag("writes", "--writes does not accept a top-level sheet selector — put sheet_name (or sheet_id) inside each writes item, same as +batch-update sub-ops")
}
raw, err := requireJSONArray(runtime, "writes")
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, sheetsValidationForFlag("writes", "--writes must be a non-empty JSON array of {sheet_name, range, cells} items")
}
if len(raw) > maxBatchOperations {
return nil, sheetsValidationForFlag("writes", "--writes accepts at most %d items; got %d — merge adjacent regions or split into several calls", maxBatchOperations, len(raw))
}
topLevelOverwrite := runtime.Bool("allow-overwrite")
ops := make([]interface{}, 0, len(raw))
var probs []error
var totalCells int64
for i, v := range raw {
item, ok := v.(map[string]interface{})
if !ok {
probs = append(probs, common.ValidationErrorf("--writes[%d] must be an object like {\"sheet_name\":…,\"range\":…,\"cells\":[[…]]}", i))
continue
}
if err := normalizeSubOpInputKeys("+cells-set", item); err != nil {
probs = append(probs, common.ValidationErrorf("--writes[%d]: %v", i, err))
continue
}
if topLevelOverwrite {
if _, has := item["allow_overwrite"]; !has {
item["allow_overwrite"] = true
}
}
fv := newMapFlagViewForCommand("+cells-set", item)
sheetID := strings.TrimSpace(fv.Str("sheet-id"))
sheetName := strings.TrimSpace(fv.Str("sheet-name"))
input, err := cellsSetInput(fv, token, sheetID, sheetName)
if err != nil {
probs = append(probs, common.ValidationErrorf("--writes[%d]: %v", i, err))
continue
}
if cells, ok := input["cells"].([]interface{}); ok {
for _, row := range cells {
if r, ok := row.([]interface{}); ok {
totalCells += int64(len(r))
}
}
}
if err := checkBatchStampBudget(totalCells); err != nil {
return nil, err
}
ops = append(ops, map[string]interface{}{
"tool_name": "set_cell_range",
"input": input,
})
}
if err := joinWritesValidationErrors(probs); err != nil {
return nil, err
}
return ops, nil
}
// joinWritesValidationErrors mirrors joinStyleValidationErrors for --writes:
// every item's first error in one message, so the whole payload is fixed in
// a single retry.
func joinWritesValidationErrors(probs []error) error {
switch len(probs) {
case 0:
return nil
case 1:
return probs[0]
}
const maxShown = 8
msgs := make([]string, 0, len(probs))
for _, e := range probs {
if p, ok := errs.ProblemOf(e); ok {
msgs = append(msgs, p.Message)
continue
}
msgs = append(msgs, e.Error())
}
suffix := ""
if len(msgs) > maxShown {
suffix = fmt.Sprintf(" (+%d more)", len(msgs)-maxShown)
msgs = msgs[:maxShown]
}
return common.ValidationErrorf("--writes has %d issues: %s%s", len(probs), strings.Join(msgs, " | "), suffix)
}
func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
@@ -91,9 +234,13 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
if err := normalizeTypedCellsStyleAliases(cells, "--cells"); err != nil {
return nil, err
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
if err := checkCellsMatchRange(cells, rangeStr); err != nil {
return nil, err
}
input := map[string]interface{}{
"excel_id": token,
"range": strings.TrimSpace(runtime.Str("range")),
"range": rangeStr,
"cells": cells,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
@@ -124,7 +271,11 @@ var CellsSetStyle = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-set-style"),
Validate: validateViaInput(cellsSetStyleInput),
Tips: []string{
`Example: lark-cli sheets +cells-set-style --url <URL> --sheet-name Sheet1 --range A1:D1 --font-weight bold --background-color "#F0F0F0" --horizontal-alignment center`,
`Borders take JSON: --border-styles '{"top":{"style":"solid","weight":"thin","color":"#000000"}}' (sides: top/bottom/left/right).`,
},
Validate: validateViaInput(cellsSetStyleInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
@@ -625,6 +776,43 @@ func warnDropdownSourceRangeHighlight(runtime *common.RuntimeContext) {
// and returns its row / column counts. Errors on non-rectangular forms like
// "A:C" (whole-column) or "3:6" (whole-row) — those need a row/col total
// from get_sheet_structure, outside the scope of pure local parsing.
// checkCellsMatchRange rejects, before any network call, the cells-vs-range
// mismatches the server would otherwise fail mid-batch ("cells row count (N)
// does not match range row count (M)" — a recurring server-side error cluster
// in eval traces, and the failure leaves earlier batch sub-ops applied).
// Single-cell ranges are checked too: the server enforces the same strict
// match on a bare "A1" (07-21 rerun, 12 rows against range row count 1) —
// there is no anchor semantics on +cells-set. An unparsable range is the
// range validator's job, not ours.
func checkCellsMatchRange(cells []interface{}, rangeStr string) error {
if len(cells) == 0 {
return sheetsValidationForFlag("cells",
"--cells is empty; to clear values use +cells-clear --scope content (needs --yes), or pass a non-empty 2D array")
}
rows, cols, err := rangeDimensions(rangeStr)
if err != nil {
return nil //nolint:nilerr // an unparsable range is reported by the range validation path with proper context
}
if len(cells) != rows {
return sheetsValidationForFlag("cells",
"--cells has %d rows but --range %q spans %d rows; make them equal (e.g. write N rows to an N-row range)",
len(cells), rangeStr, rows)
}
for r, rowRaw := range cells {
row, ok := rowRaw.([]interface{})
if !ok {
return sheetsValidationForFlag("cells",
"--cells[%d] must be an array (one row of cells) — --cells is always a 2D array, a single cell is [[{…}]]", r)
}
if len(row) != cols {
return sheetsValidationForFlag("cells",
"--cells[%d] has %d columns but --range %q spans %d columns; every row must match the range width",
r, len(row), rangeStr, cols)
}
}
return nil
}
func rangeDimensions(rangeStr string) (rows, cols int, err error) {
if idx := strings.Index(rangeStr, "!"); idx >= 0 {
rangeStr = rangeStr[idx+1:]

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet read → file offload ───────────────────────────────────
//
// Shared plumbing for +cells-get / +csv-get / +table-get behind the
// --output-path flag: when a caller redirects a read to a file, the char cap
// should default to unlimited so the whole sheet lands on disk instead of being
// clipped by the stdout-oriented max_chars safety cap.
// readOutputPath returns the trimmed --output-path flag value ("" when unset).
func readOutputPath(runtime *common.RuntimeContext) string {
return strings.TrimSpace(runtime.Str("output-path"))
}
// maxCharsInput resolves the max_chars value to send to the underlying read
// tool. With --output-path set the cap is lifted (unbounded sentinel) so the
// full result is written to the file; otherwise the --max-chars value binds.
// The second return is false when nothing should be sent (max-chars <= 0), in
// which case the tool's own default applies. Note the tool truncates at ~50000
// even when max_chars is omitted, so callers that want an explicit cap should
// pass a positive default.
func maxCharsInput(runtime *common.RuntimeContext) (int, bool) {
if readOutputPath(runtime) != "" {
return unboundedReadLimit, true
}
if n := runtime.Int("max-chars"); n > 0 {
return n, true
}
return 0, false
}
// emitReadResult delivers a read shortcut's result. When --output-path is set it
// writes the data payload to that path as pretty JSON and prints a small
// confirmation envelope to stdout (path + byte count); otherwise it prints the
// full result envelope to stdout as usual.
func emitReadResult(runtime *common.RuntimeContext, out interface{}) error {
path := readOutputPath(runtime)
if path == "" {
runtime.Out(out, nil)
return nil
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
if _, err := runtime.FileIO().Save(path, fileio.SaveOptions{}, bytes.NewReader(b)); err != nil {
return err
}
resolved, err := runtime.FileIO().ResolvePath(path)
if err != nil {
resolved = path
}
runtime.Out(map[string]interface{}{
"output_path": resolved,
"bytes_written": len(b),
}, nil)
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/util"
@@ -83,7 +84,7 @@ func callTool(
code, _ := util.ToFloat64(envelope["code"])
if code != 0 {
msg, _ := envelope["msg"].(string)
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), msg).
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), flattenToolErrorMsg(msg)).
WithCode(int(code))
}
data, _ := envelope["data"].(map[string]interface{})
@@ -100,6 +101,66 @@ func callTool(
return out, nil
}
// flattenToolErrorMsg unwraps the nested-escaped-JSON error payload some
// sheet-ai tools put in msg — batch_update in particular wraps its result as
// {"error":"{\"message\":\"batch_update: N succeeded, M failed\",
// \"failures\":[…]}","errorType":…,"data":{…}} — into one readable line
// naming each failed operation. Eval traces show agents (and even the eval
// aggregator) failing to extract the real cause from the double-escaped
// form. Anything that doesn't match the nested shape passes through
// untouched.
func flattenToolErrorMsg(msg string) string {
trimmed := strings.TrimSpace(msg)
if !strings.HasPrefix(trimmed, "{") {
return msg
}
var outer struct {
Error string `json:"error"`
}
if json.Unmarshal([]byte(trimmed), &outer) != nil || strings.TrimSpace(outer.Error) == "" {
return msg
}
inner := strings.TrimSpace(outer.Error)
var detail struct {
Message string `json:"message"`
Failures []struct {
Index int `json:"index"`
ToolName string `json:"tool_name"`
Error string `json:"error"`
} `json:"failures"`
}
if strings.HasPrefix(inner, "{") && json.Unmarshal([]byte(inner), &detail) == nil && detail.Message != "" {
if len(detail.Failures) == 0 {
return detail.Message
}
parts := make([]string, 0, len(detail.Failures))
firstFailed := detail.Failures[0].Index
for _, f := range detail.Failures {
parts = append(parts, fmt.Sprintf("operations[%d] (%s): %s", f.Index, f.ToolName, f.Error))
if f.Index < firstFailed {
firstFailed = f.Index
}
}
out := detail.Message + " — " + strings.Join(parts, "; ")
// Partial failure is NOT rolled back server-side: the succeeded sub-ops
// stay applied. Spell out the recovery so agents don't resend the whole
// batch and double-apply the successes (observed in eval traces). With
// one failure (fail-fast) everything before it succeeded and nothing
// after it ran — resend from that index; with several (continue-on-error)
// only the listed failures need resending.
if strings.Contains(detail.Message, "succeeded") &&
!strings.Contains(detail.Message, " 0 succeeded") {
if len(detail.Failures) == 1 {
out += fmt.Sprintf("; note: succeeded operations stay applied (no rollback) — fix the failure and resend only operations[%d:] onward, do not resend the whole batch", firstFailed)
} else {
out += "; note: succeeded operations stay applied (no rollback) — fix and resend only the failed operations listed above, do not resend the whole batch"
}
}
return out
}
return inner
}
// invokeToolDryRun renders the One-OpenAPI request the shortcut would send.
// The wire-format body (with input serialized to a JSON string) is preserved
// for fidelity, and a decoded tool_input map is surfaced alongside so humans

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// TestFlattenToolErrorMsg pins the unwrap of batch_update's double-escaped
// error payload (the exact shape from eval V2U038/V2U013 traces) and the
// pass-through of everything else.
func TestFlattenToolErrorMsg(t *testing.T) {
t.Parallel()
t.Run("batch failures flatten to one line", func(t *testing.T) {
t.Parallel()
msg := `{"error":"{\"message\":\"batch_update: 0 succeeded, 1 failed\",\"succeeded\":0,\"failed\":1,\"failures\":[{\"index\":0,\"tool_name\":\"manage_chart_object\",\"error\":\"invalid snapshot.data.dim1.serie.index: 0, must be >= 1 (index is 1-based)\",\"errorType\":\"param_error\"}]}","errorType":"param_error","data":{"total":2,"succeeded":0,"failed":1}}`
got := flattenToolErrorMsg(msg)
for _, want := range []string{
"batch_update: 0 succeeded, 1 failed",
"operations[0] (manage_chart_object): invalid snapshot.data.dim1.serie.index",
} {
if !strings.Contains(got, want) {
t.Errorf("flattened msg should contain %q, got %q", want, got)
}
}
if strings.Contains(got, `\"`) {
t.Errorf("flattened msg must not carry escaped JSON, got %q", got)
}
})
t.Run("plain-string inner error unwraps", func(t *testing.T) {
t.Parallel()
got := flattenToolErrorMsg(`{"error":"sheet \"s\" not found","errorType":"param_error"}`)
if got != `sheet "s" not found` {
t.Errorf("got %q", got)
}
})
t.Run("non-JSON msg passes through", func(t *testing.T) {
t.Parallel()
msg := `cell at row 0, col 1 is inside a merged region (top-left: A1)`
if got := flattenToolErrorMsg(msg); got != msg {
t.Errorf("got %q", got)
}
})
t.Run("JSON without error field passes through", func(t *testing.T) {
t.Parallel()
msg := `{"detail":"x"}`
if got := flattenToolErrorMsg(msg); got != msg {
t.Errorf("got %q", got)
}
})
}

View File

@@ -35,6 +35,11 @@ func Shortcuts() []common.Shortcut {
if hasFlag(all[i].Flags, "spreadsheet-token") {
all[i].PostMount = withTokenAlias(all[i].PostMount)
}
// +chart-create grows --print-example (minimal per-type --properties
// templates) — the biggest --print-schema consumer in eval traces.
if all[i].Command == "+chart-create" {
all[i].PostMount = withChartPrintExample(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.
@@ -153,6 +158,9 @@ func shortcutList() []common.Shortcut {
SparklineCreate, SparklineUpdate, SparklineDelete,
FloatImageCreate, FloatImageUpdate, FloatImageDelete,
// lark_sheet_styles_put
StylesPut,
// lark_sheet_batch_update
BatchUpdate,
CellsBatchSetStyle,

View File

@@ -0,0 +1,456 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"fmt"
"slices"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── style vocabulary acceptance layer ────────────────────────────────
//
// The single home for how the sheets domain ACCEPTS style vocabulary, across
// all three carrier paths that end in set_cell_range bodies:
//
// flag path +cells-set-style / +cells-batch-set-style flat flags
// typed cells +cells-set --cells cell objects (incl. batch sub-ops)
// styles payload --styles on +workbook-create / +table-put / +styles-put
//
// Design contract (established in the 2026-07 batch-update overhaul; see the
// acceptance tests in styles_acceptance_test.go):
//
// - ONE canonical form, documented; a WIDE acceptance layer, undocumented.
// Model priors are divergent (one eval batch produced six different
// border spellings), so no canonical structure can make first tries
// succeed — acceptance is normalized here instead, never per-call-site.
// - Every rewrite must be unambiguous; ambiguous guesses (fore_color) get
// a targeted prescription, never a silent pick. Silent ignoring and
// bare rejection are both bugs.
// - SILENT-ALIAS ADMISSION BAR (2026-07-21): only words from REAL external
// vocabularies (Excel/openpyxl, CSS, Google Sheets API), recurring
// across batches or ≥3 tasks in one, with zero semantic ambiguity.
// Spelling/word-order permutations do NOT get aliases — they are
// absorbed by the universal did-you-mean rejection (one self-healing
// retry, zero per-variant code). Real vocabularies are a finite set;
// permutations are not. Earlier permutation aliases are grandfathered.
// - Closure is enforced by two test properties: vocabulary parity (every
// flag-path style must be accepted on the payload paths) and the prior
// corpus (every observed model spelling either normalizes or
// prescribes). New eval finding → corpus row → fix HERE → locked.
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
// 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{} {
style := map[string]interface{}{}
if v := runtime.Str("background-color"); v != "" {
style["background_color"] = v
}
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")
}
if v := runtime.Str("font-style"); v != "" {
style["font_style"] = v
}
if v := runtime.Str("font-weight"); v != "" {
style["font_weight"] = v
}
if v := runtime.Str("font-line"); v != "" {
style["font_line"] = v
}
if v := runtime.Str("horizontal-alignment"); v != "" {
style["horizontal_alignment"] = v
}
if v := runtime.Str("vertical-alignment"); v != "" {
style["vertical_alignment"] = v
}
if v := runtime.Str("word-wrap"); v != "" {
style["word_wrap"] = v
}
if v := runtime.Str("number-format"); v != "" {
style["number_format"] = v
}
return style
}
// cellStyleAliases maps shorthand cell_styles field names that models commonly
// hallucinate (Excel / openpyxl / CSS conventions) onto the canonical field
// names the backend expects. Only the unambiguous alignment shorthands are
// aliased — they are the high-frequency miss; ambiguous guesses (e.g. "color",
// "bg_color", "text_align") are intentionally left out so a wrong guess still
// surfaces as an error rather than being silently reinterpreted.
var cellStyleAliases = []struct{ alias, canonical string }{
{"horizontal_align", "horizontal_alignment"},
{"halign", "horizontal_alignment"},
{"vertical_align", "vertical_alignment"},
{"valign", "vertical_alignment"},
// wrap family: word_wrap is the sole wrap concept, no ambiguity. 07-20
// eval: wrap_text alone produced an 88-issue retry loop on --styles;
// wrap_strategy (the Google Sheets API word) followed on 07-21.
{"wrap_text", "word_wrap"},
{"text_wrap", "word_wrap"},
{"wrap_strategy", "word_wrap"},
}
// cellStyleEnumFields sources the enum vocabulary for enum-bearing
// cell_styles fields from the +cells-set-style flag-defs, so the payload path
// (--styles / typed --cells) validates and canonicalizes values the same way
// the cobra flag path does. 07-20 eval: "vertical_alignment":"center" (CSS
// vocabulary; Lark spells it "middle") passed the CLI and burned a
// server-side round trip ~10 times — the flag path had normalized it since
// round 2, the payload path never did.
func cellStyleEnumFields() map[string][]string {
defs, err := loadFlagDefs()
if err != nil {
return nil
}
spec, ok := defs["+cells-set-style"]
if !ok {
return nil
}
out := map[string][]string{}
for _, df := range spec.Flags {
if df.Kind != "own" || df.Type != "string" || len(df.Enum) == 0 {
continue
}
out[strings.ReplaceAll(df.Name, "-", "_")] = df.Enum
}
return out
}
// normalizeCellStyleAliases renames known shorthand keys in a single
// cell_styles map to their canonical equivalents, in place, so a model that
// writes e.g. "horizontal_align" instead of "horizontal_alignment" still
// applies the style instead of hitting an "unsupported field" error (--styles)
// or having the field silently dropped by the backend (typed --cells). If both
// the shorthand and its canonical key are present it returns a validation error
// rather than picking one. It then canonicalizes enum VALUES (casing + known
// cross-vocabulary aliases like CSS "center" → Lark "middle"; boolean
// word_wrap → the enum) and rejects off-enum values client-side instead of
// letting the server fail the whole batch. path labels the map for errors.
func normalizeCellStyleAliases(style map[string]interface{}, path string) error {
if len(style) == 0 {
return nil
}
for _, a := range cellStyleAliases {
v, ok := style[a.alias]
if !ok {
continue
}
if _, exists := style[a.canonical]; exists {
return common.ValidationErrorf("%s.%s conflicts with %s; pass only %s", path, a.alias, a.canonical, a.canonical)
}
style[a.canonical] = v
delete(style, a.alias)
}
// fore_color is deliberately NOT aliased: in openpyxl vocabulary fgColor
// is the FILL color while a plain reading suggests the font color — a
// silent pick could color the wrong thing. Prescribe both options.
if _, has := style["fore_color"]; has {
return common.ValidationErrorf("%s.fore_color is ambiguous — use font_color for text color or background_color for the cell fill", path)
}
// Boolean wrap habit: true unambiguously means wrap on, false means off.
if b, isBool := style["word_wrap"].(bool); isBool {
if b {
style["word_wrap"] = "auto-wrap"
} else {
style["word_wrap"] = "overflow"
}
}
for field, enum := range cellStyleEnumFields() {
raw, has := style[field]
if !has {
continue
}
val, isStr := raw.(string)
if !isStr || val == "" || slices.Contains(enum, val) {
continue
}
if canon := canonicalEnumValue(val, enum); canon != "" {
style[field] = canon
continue
}
msg := fmt.Sprintf("%s.%s value %q is invalid (allowed: %s)", path, field, val, strings.Join(enum, ", "))
if match := closestEnumValue(val, enum); match != "" {
msg += fmt.Sprintf("; did you mean %q?", match)
}
return common.ValidationErrorf("%s", msg)
}
return nil
}
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
// alignment shorthands are accepted on +cells-set the same as on --styles.
// It also expands the border "all" shorthand and intercepts border_styles
// mis-nested inside cell_styles — both server-rejected shapes that eval
// traces show surviving CLI validation and costing a full network round
// trip. Structure is checked leniently to match the pass-through contract:
// any element that isn't the expected shape is skipped, not rejected.
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
for r, rowRaw := range cells {
row, ok := rowRaw.([]interface{})
if !ok {
continue
}
for c, cellRaw := range row {
cell, ok := cellRaw.(map[string]interface{})
if !ok {
continue
}
// cells[][].style is the habitual spelling of cell_styles (recurring
// server-side 900015206 in eval traces) — rewrite when unambiguous.
if styleObj, isObj := cell["style"].(map[string]interface{}); isObj {
if _, has := cell["cell_styles"]; has {
return common.ValidationErrorf("%s[%d][%d].style conflicts with cell_styles; pass only cell_styles", path, r, c)
}
cell["cell_styles"] = styleObj
delete(cell, "style")
}
// cells[][].type is not a cell field; the value type is whatever the
// JSON value is. Reject with the fix instead of a server round trip.
if _, has := cell["type"]; has {
return common.ValidationErrorf("%s[%d][%d].type is not a cell field — the value type is inferred from the JSON value; control display format via cell_styles.number_format", path, r, c)
}
if bs, ok := cell["border_styles"].(map[string]interface{}); ok {
expandBorderAllShorthand(bs)
}
st, ok := cell["cell_styles"].(map[string]interface{})
if !ok {
continue
}
if _, misNested := st["border_styles"]; misNested {
return common.ValidationErrorf(
"%s[%d][%d].cell_styles.border_styles is not valid — border_styles is a top-level cell field, a sibling of cell_styles; move it up one level",
path, r, c)
}
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
return err
}
}
}
return nil
}
// expandBorderAllShorthand rewrites the "all" side shorthand — habitual from
// Excel / openpyxl vocabulary, rejected by the backend — into the four
// explicit sides, in place. An explicitly set side wins over the shorthand.
// Applied on both the typed --cells path and the --styles path, so batch
// sub-ops get the same rewrite as standalone calls.
func expandBorderAllShorthand(border map[string]interface{}) {
if all, ok := border["all"]; ok {
for _, side := range []string{"top", "bottom", "left", "right"} {
if _, exists := border[side]; !exists {
border[side] = all
}
}
delete(border, "all")
}
// Weight vocabulary in the style slot ("thin"/"medium"/"thick" are the
// habitual Excel words; the largest residual styles cluster in the 07-21
// rerun wrote them into border_styles.<side>.style of the FULL nested
// form). A thin border always means a thin solid line: move the word to
// weight and default style to solid. Only when weight is absent — an
// explicit conflicting weight keeps the enum error path.
for _, raw := range border {
side, ok := raw.(map[string]interface{})
if !ok {
continue
}
s, _ := side["style"].(string)
switch strings.ToLower(s) {
case "thin", "medium", "thick":
if _, hasWeight := side["weight"]; !hasWeight {
side["weight"] = strings.ToLower(s)
side["style"] = "solid"
}
}
}
}
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
// left/right with style sub-objects), expanding the "all" side shorthand the
// same as the typed --cells and --styles paths so +cells-set-style /
// +cells-batch-set-style don't ship {"all":…} for the backend to reject.
// Returns nil when the flag is empty.
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
if runtime.Str("border-styles") == "" {
return nil, nil
}
v, err := parseJSONFlag(runtime, "border-styles")
if err != nil {
return nil, err
}
m, ok := v.(map[string]interface{})
if !ok {
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
}
expandBorderAllShorthand(m)
return m, nil
}
// requireAnyStyleFlag ensures at least one style-defining flag (style or
// border) is set — otherwise the request would do nothing.
func requireAnyStyleFlag(runtime flagView) error {
if len(buildCellStyleFromFlags(runtime)) > 0 {
return nil
}
if runtime.Str("border-styles") != "" {
return nil
}
return common.ValidationErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)").
WithParams(
sheetsInvalidParam("background-color", "required; specify at least one style flag"),
sheetsInvalidParam("font-weight", "required; specify at least one style flag"),
sheetsInvalidParam("border-styles", "required; specify at least one style flag"),
)
}
// foldBorderFamilyAliases rewrites the habitual flattened border vocabulary
// (Excel / openpyxl conventions) into the canonical nested border_styles
// object, in place. 07-20 eval: the border family alone accounted for the
// largest --styles error cluster (borders / border / border_bottom /
// border_style / border_top_color / …), each burning a full payload retry.
// Accepted rewrites, all unambiguous:
//
// borders / border (object) → border_styles (side-keyed) or border_styles.all (attr-keyed)
// border_top|bottom|left|right (object) → border_styles.<side>
// border_style|color|weight (scalar) → border_styles.all.<attr>
// border_<side>_<style|color|weight> (scalar) → border_styles.<side>.<attr>
//
// A border_style value from the WEIGHT vocabulary (thin/medium/thick — the
// habitual Excel word) sets weight and defaults style to solid: a "thin
// border" always means a thin solid line. Conflicts with an explicitly given
// border_styles error out instead of picking a side.
func foldBorderFamilyAliases(in map[string]interface{}, path string) error {
sides := map[string]bool{"top": true, "bottom": true, "left": true, "right": true, "all": true}
attrs := map[string]bool{"style": true, "color": true, "weight": true}
borderWeights := map[string]bool{"thin": true, "medium": true, "thick": true}
ensureBorder := func() map[string]interface{} {
bs, ok := in["border_styles"].(map[string]interface{})
if !ok {
bs = map[string]interface{}{}
in["border_styles"] = bs
}
return bs
}
setSideAttr := func(side, attr string, v interface{}, from string) error {
bs := ensureBorder()
sideObj, ok := bs[side].(map[string]interface{})
if !ok {
if _, exists := bs[side]; exists {
return common.ValidationErrorf("%s.%s conflicts with border_styles.%s; keep one form", path, from, side)
}
sideObj = map[string]interface{}{}
bs[side] = sideObj
}
if _, exists := sideObj[attr]; exists {
return common.ValidationErrorf("%s.%s conflicts with border_styles.%s.%s; keep one form", path, from, side, attr)
}
sideObj[attr] = v
return nil
}
setSide := func(side string, v interface{}, from string) error {
obj, ok := v.(map[string]interface{})
if !ok {
return common.ValidationErrorf("%s.%s must be an object like {\"style\":\"solid\",\"color\":\"#000000\"}", path, from)
}
for attr, av := range obj {
if !attrs[attr] {
return common.ValidationErrorf("%s.%s.%s is not a border attribute (want style/weight/color)", path, from, attr)
}
if err := setSideAttr(side, attr, av, from); err != nil {
return err
}
}
return nil
}
// border_style with a weight-vocabulary value means "thin solid line".
setAllScalar := func(attr string, v interface{}, from string) error {
if attr == "style" {
if s, ok := v.(string); ok && borderWeights[strings.ToLower(s)] {
if err := setSideAttr("all", "weight", strings.ToLower(s), from); err != nil {
return err
}
return setSideAttr("all", "style", "solid", from)
}
}
return setSideAttr("all", attr, v, from)
}
for _, key := range []string{"borders", "border"} {
v, has := in[key]
if !has {
continue
}
obj, ok := v.(map[string]interface{})
if !ok {
return common.ValidationErrorf("%s.%s must be an object — either side-keyed ({\"top\":{…},\"bottom\":{…}} / {\"all\":{…}}) or attribute-keyed ({\"style\":\"solid\",\"color\":\"#000\"} = all four sides)", path, key)
}
sideKeyed := false
for k := range obj {
if sides[k] {
sideKeyed = true
break
}
}
if sideKeyed {
for side, sv := range obj {
if !sides[side] {
return common.ValidationErrorf("%s.%s.%s is not a valid side (want top/bottom/left/right/all)", path, key, side)
}
if err := setSide(side, sv, key); err != nil {
return err
}
}
} else if err := setSide("all", v, key); err != nil {
return err
}
delete(in, key)
}
for _, side := range []string{"top", "bottom", "left", "right"} {
// Both word orders appear in the wild: border_bottom (07-20 eval) and
// bottom_border (07-21), same for the flattened attribute triples.
for _, key := range []string{"border_" + side, side + "_border"} {
if v, has := in[key]; has {
if err := setSide(side, v, key); err != nil {
return err
}
delete(in, key)
}
}
for attr := range attrs {
for _, key := range []string{"border_" + side + "_" + attr, side + "_border_" + attr} {
if v, has := in[key]; has {
if err := setSideAttr(side, attr, v, key); err != nil {
return err
}
delete(in, key)
}
}
}
}
for attr := range attrs {
key := "border_" + attr
if v, has := in[key]; has {
if err := setAllScalar(attr, v, key); err != nil {
return err
}
delete(in, key)
}
}
return nil
}

View File

@@ -0,0 +1,410 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"fmt"
"strings"
"testing"
)
// ─── styles acceptance contract ───────────────────────────────────────
//
// Two closure properties that turn the --styles acceptance surface from
// "endless patching" into a locked contract (07-20 rerun lesson: the
// redesign moved traffic onto the payload path while the flag path's
// forgiveness layers stayed behind):
//
// 1. Vocabulary parity — every style the flag path (+cells-set-style)
// can express must be accepted verbatim by the payload path.
// 2. Prior corpus — every model spelling observed in eval traces must
// either normalize to the canonical form or produce a targeted
// prescription. Silent ignoring and bare rejection are both bugs.
// New eval finding → add a corpus row → fix → locked forever.
// acceptStyleItem runs one cell_styles item through the styles-put pipeline
// and returns the emitted cell prototype (cell_styles/border_styles) or the
// error.
func acceptStyleItem(t *testing.T, fields map[string]interface{}) (map[string]interface{}, error) {
t.Helper()
item := map[string]interface{}{"range": "A1:B2"}
for k, v := range fields {
item[k] = v
}
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "S1",
"cell_styles": []interface{}{item},
}},
}), testToken)
if err != nil {
return nil, err
}
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
cells := input["cells"].([][]interface{})
return cells[0][0].(map[string]interface{}), nil
}
// TestStylesAcceptance_VocabularyParity locks property 1: iterate the
// +cells-set-style flag vocabulary from flag-defs and assert the payload
// path accepts each field with a valid value and emits it.
func TestStylesAcceptance_VocabularyParity(t *testing.T) {
t.Parallel()
defs, err := loadFlagDefs()
if err != nil {
t.Fatalf("loadFlagDefs: %v", err)
}
spec, ok := defs["+cells-set-style"]
if !ok {
t.Fatal("no +cells-set-style flag defs")
}
sample := func(df flagDef) interface{} {
if len(df.Enum) > 0 {
return df.Enum[0]
}
switch df.Type {
case "float64", "int":
return float64(12)
}
switch df.Name {
case "font-family":
return "Arial"
case "number-format":
return "0.00"
default: // colors and any future string field
return "#112233"
}
}
for _, df := range spec.Flags {
if df.Kind != "own" || df.Name == "range" {
continue
}
df := df
t.Run(df.Name, func(t *testing.T) {
t.Parallel()
field := strings.ReplaceAll(df.Name, "-", "_")
var value interface{}
if df.Name == "border-styles" {
value = map[string]interface{}{"all": map[string]interface{}{"style": "solid"}}
} else {
value = sample(df)
}
proto, err := acceptStyleItem(t, map[string]interface{}{field: value})
if err != nil {
t.Fatalf("payload path rejects flag-path field %s: %v", field, err)
}
if df.Name == "border-styles" {
if _, ok := proto["border_styles"].(map[string]interface{}); !ok {
t.Fatalf("border_styles not emitted: %v", proto)
}
return
}
cs, _ := proto["cell_styles"].(map[string]interface{})
if cs == nil || cs[field] == nil {
t.Fatalf("field %s silently dropped: %v", field, proto)
}
})
}
}
// stylesPriorCorpus is the observed-model-spelling corpus (source: eval
// batches 2026-07-08 → 07-20). Every row must either normalize (checked via
// wantCell) or produce a targeted prescription (wantErr). Add a row for every
// new spelling an eval surfaces — never let one be silently ignored.
var stylesPriorCorpus = []struct {
name string
fields map[string]interface{}
wantErr string // "" = must be accepted
check func(proto map[string]interface{}) string // "" = ok, else failure detail
}{
// border family (07-20: largest cluster)
{name: "borders attr-keyed means all sides",
fields: map[string]interface{}{"borders": map[string]interface{}{"style": "solid", "color": "#DDDDDD"}},
check: wantBorder("top", "style", "solid")},
{name: "border side-keyed",
fields: map[string]interface{}{"border": map[string]interface{}{"top": map[string]interface{}{"style": "solid"}}},
check: wantBorder("top", "style", "solid")},
{name: "border_bottom object",
fields: map[string]interface{}{"border_bottom": map[string]interface{}{"style": "solid"}},
check: wantBorder("bottom", "style", "solid")},
{name: "border_style weight-vocabulary means thin solid",
fields: map[string]interface{}{"border_style": "thin"},
check: wantBorder("top", "weight", "thin")},
{name: "border_style style-vocabulary",
fields: map[string]interface{}{"border_style": "dashed"},
check: wantBorder("top", "style", "dashed")},
{name: "border_color scalar",
fields: map[string]interface{}{"border_color": "#FF0000"},
check: wantBorder("top", "color", "#FF0000")},
{name: "border_top_color flattened",
fields: map[string]interface{}{"border_top_color": "#FF0000"},
check: wantBorder("top", "color", "#FF0000")},
{name: "border_left_weight flattened",
fields: map[string]interface{}{"border_left_weight": "thin"},
check: wantBorder("left", "weight", "thin")},
{name: "border_styles invalid side prescribed",
fields: map[string]interface{}{"border_styles": map[string]interface{}{"outer": map[string]interface{}{"style": "solid"}}},
wantErr: "not a valid side"},
// wrap family
{name: "wrap_text boolean", fields: map[string]interface{}{"wrap_text": true}, check: wantStyle("word_wrap", "auto-wrap")},
{name: "text_wrap string", fields: map[string]interface{}{"text_wrap": "auto-wrap"}, check: wantStyle("word_wrap", "auto-wrap")},
{name: "word_wrap false", fields: map[string]interface{}{"word_wrap": false}, check: wantStyle("word_wrap", "overflow")},
// alignment family
{name: "horizontal_align shorthand", fields: map[string]interface{}{"horizontal_align": "center"}, check: wantStyle("horizontal_alignment", "center")},
{name: "valign shorthand", fields: map[string]interface{}{"valign": "top"}, check: wantStyle("vertical_alignment", "top")},
{name: "CSS center for vertical", fields: map[string]interface{}{"vertical_alignment": "center"}, check: wantStyle("vertical_alignment", "middle")},
{name: "casing normalized", fields: map[string]interface{}{"font_weight": "BOLD"}, check: wantStyle("font_weight", "bold")},
// weight vocabulary in the FULL nested form's style slot (07-21 rerun:
// the dominant residual — 8 tasks wrote border_styles.<side>.style:"thin")
{name: "full-form thin in style slot",
fields: map[string]interface{}{"border_styles": map[string]interface{}{"top": map[string]interface{}{"style": "thin"}}},
check: wantBorder("top", "weight", "thin")},
{name: "full-form all-shorthand medium in style slot",
fields: map[string]interface{}{"border_styles": map[string]interface{}{"all": map[string]interface{}{"style": "medium"}}},
check: wantBorder("bottom", "weight", "medium")},
// side-first word order + Google Sheets wrap word (07-21 evening batch)
{name: "side-first bottom_border object",
fields: map[string]interface{}{"bottom_border": map[string]interface{}{"style": "solid"}},
check: wantBorder("bottom", "style", "solid")},
{name: "side-first bottom_border_style scalar",
fields: map[string]interface{}{"bottom_border_style": "solid"},
check: wantBorder("bottom", "style", "solid")},
{name: "wrap_strategy aliases to word_wrap",
fields: map[string]interface{}{"wrap_strategy": "auto-wrap"},
check: wantStyle("word_wrap", "auto-wrap")},
// prescriptions (ambiguous / unsupported / typo)
{name: "fore_color prescribed", fields: map[string]interface{}{"fore_color": "#F00"}, wantErr: "ambiguous"},
{name: "indent rejected not ignored", fields: map[string]interface{}{"indent": float64(2)}, wantErr: "not a supported style field"},
{name: "unknown field carries did-you-mean and the field list",
fields: map[string]interface{}{"fontcolor": "#000000"}, wantErr: `did you mean "font_color"`},
{name: "enum typo gets did-you-mean", fields: map[string]interface{}{"vertical_alignment": "botom"}, wantErr: "did you mean"},
}
func wantStyle(field, want string) func(map[string]interface{}) string {
return func(proto map[string]interface{}) string {
cs, _ := proto["cell_styles"].(map[string]interface{})
if cs == nil || cs[field] != want {
return fmt.Sprintf("cell_styles.%s = %v, want %q", field, cs[field], want)
}
return ""
}
}
func wantBorder(side, attr, want string) func(map[string]interface{}) string {
return func(proto map[string]interface{}) string {
bs, _ := proto["border_styles"].(map[string]interface{})
sideObj, _ := bs[side].(map[string]interface{})
if sideObj == nil || sideObj[attr] != want {
return fmt.Sprintf("border_styles.%s.%s = %v, want %q", side, attr, sideObj[attr], want)
}
return ""
}
}
func TestStylesAcceptance_PriorCorpus(t *testing.T) {
t.Parallel()
for _, tc := range stylesPriorCorpus {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
proto, err := acceptStyleItem(t, tc.fields)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("want prescription containing %q, got err=%v", tc.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("corpus spelling rejected: %v", err)
}
if detail := tc.check(proto); detail != "" {
t.Fatal(detail)
}
})
}
}
// TestStylesPut_CoalescesSameStyleRanges pins the declarative-spec
// optimization: per-row entries with the identical style fuse into one
// rectangle, so row-by-row specs (07-21 rerun: 184/203/861-op expansions
// against the 100-op cap) no longer hit the cap.
func TestStylesPut_CoalescesSameStyleRanges(t *testing.T) {
t.Parallel()
t.Run("150 same-style rows fuse into one stamp", func(t *testing.T) {
t.Parallel()
entries := make([]interface{}, 0, 150)
for r := 1; r <= 150; r++ {
entries = append(entries, map[string]interface{}{
"range": fmt.Sprintf("A%d:F%d", r, r), "font_weight": "bold",
})
}
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": entries}},
}), testToken)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ops) != 1 {
t.Fatalf("got %d ops, want 1 fused stamp", len(ops))
}
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
if input["range"] != "A1:F150" {
t.Fatalf("range = %v, want A1:F150", input["range"])
}
})
t.Run("different styles stay separate", func(t *testing.T) {
t.Parallel()
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": []interface{}{
map[string]interface{}{"range": "A1:F1", "font_weight": "bold"},
map[string]interface{}{"range": "A2:F2", "background_color": "#EEEEEE"},
}}},
}), testToken)
if err != nil || len(ops) != 2 {
t.Fatalf("ops=%d err=%v, want 2", len(ops), err)
}
})
t.Run("horizontal fuse with same rows", func(t *testing.T) {
t.Parallel()
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{"name": "S1", "cell_styles": []interface{}{
map[string]interface{}{"range": "A1:C5", "font_weight": "bold"},
map[string]interface{}{"range": "D1:F5", "font_weight": "bold"},
}}},
}), testToken)
if err != nil || len(ops) != 1 {
t.Fatalf("ops=%d err=%v, want 1", len(ops), err)
}
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
if input["range"] != "A1:F5" {
t.Fatalf("range = %v, want A1:F5", input["range"])
}
})
}
// TestTypedCellsHabitualKeys pins the typed --cells cell-object fixes
// (recurring server-side 900015206 across 07-20/07-21 reruns).
func TestTypedCellsHabitualKeys(t *testing.T) {
t.Parallel()
t.Run("style object rewrites to cell_styles through batch", func(t *testing.T) {
t.Parallel()
translated, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
"sheet_name": "S1", "range": "A1",
"cells": []interface{}{[]interface{}{map[string]interface{}{
"value": "x", "style": map[string]interface{}{"font_weight": "bold"},
}}},
}), testToken, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
input := translated["input"].(map[string]interface{})
cell := input["cells"].([]interface{})[0].([]interface{})[0].(map[string]interface{})
cs, _ := cell["cell_styles"].(map[string]interface{})
if cs == nil || cs["font_weight"] != "bold" {
t.Fatalf("cell = %v, want cell_styles.font_weight bold", cell)
}
if _, has := cell["style"]; has {
t.Fatalf("style key must be renamed, got %v", cell)
}
})
t.Run("type key gets a prescription", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+cells-set", map[string]interface{}{
"sheet_name": "S1", "range": "A1",
"cells": []interface{}{[]interface{}{map[string]interface{}{
"value": "x", "type": "text",
}}},
}), testToken, 0)
requireValidation(t, err, "not a cell field")
})
}
// TestStylesAcceptance_ResizeAndMergeCorpus extends the corpus to the
// row/col_sizes and cell_merges sections.
func TestStylesAcceptance_ResizeAndMergeCorpus(t *testing.T) {
t.Parallel()
runSection := func(section string, entry interface{}) ([]interface{}, error) {
return stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "S1",
section: []interface{}{entry},
}},
}), testToken)
}
pixelValue := func(t *testing.T, ops []interface{}, key string) interface{} {
t.Helper()
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
block, _ := input[key].(map[string]interface{})
if block == nil || block["type"] != "pixel" {
t.Fatalf("%s = %v, want pixel block", key, input[key])
}
return block["value"]
}
t.Run("size alone implies pixel", func(t *testing.T) {
t.Parallel()
ops, err := runSection("row_sizes", map[string]interface{}{"range": "1:1", "size": float64(36)})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v := pixelValue(t, ops, "resize_height"); v != 36 {
t.Fatalf("value = %v, want 36", v)
}
})
t.Run("width alone implies pixel on col_sizes", func(t *testing.T) {
t.Parallel()
ops, err := runSection("col_sizes", map[string]interface{}{"range": "A:C", "width": float64(120)})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v := pixelValue(t, ops, "resize_width"); v != 120 {
t.Fatalf("value = %v, want 120", v)
}
})
t.Run("type auto still works on rows", func(t *testing.T) {
t.Parallel()
if _, err := runSection("row_sizes", map[string]interface{}{"range": "1:1", "type": "auto"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("neither size nor type prescribed", func(t *testing.T) {
t.Parallel()
_, err := runSection("row_sizes", map[string]interface{}{"range": "1:1"})
requireValidation(t, err, "needs size (px) or type")
})
t.Run("wrong-dimension word prescribed", func(t *testing.T) {
t.Parallel()
_, err := runSection("col_sizes", map[string]interface{}{"range": "A:C", "height": float64(36)})
requireValidation(t, err, "does not apply")
})
t.Run("raw OpenAPI merge_type accepted", func(t *testing.T) {
t.Parallel()
ops, err := runSection("cell_merges", map[string]interface{}{"range": "A1:B2", "merge_type": "MERGE_ALL"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
if input["merge_type"] != "all" {
t.Fatalf("merge_type = %v, want all", input["merge_type"])
}
})
t.Run("bare string merge accepted", func(t *testing.T) {
t.Parallel()
if _, err := runSection("cell_merges", "A1:B2"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}

View File

@@ -0,0 +1,250 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// TestTablePut_StylesErrorsAggregate pins the one-retry contract for
// --styles: every issue across sections and ops is reported in a single
// error (eval V2U032 burned three round trips fixing a border side, then
// row_sizes.type, then size — each surfaced only after the previous fix).
func TestTablePut_StylesErrorsAggregate(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+table-put")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"s","columns":["a"],"data":[["x"]]}]}`,
"--styles", `{"styles":[{"name":"s",
"cell_styles":[{"range":"A1:A1","border_styles":{"horizontal":{"style":"solid"}}}],
"row_sizes":[{"range":"1:1","type":"custom"}],
"col_sizes":[{"range":"A:A","type":"pixel"}]}]}`,
"--dry-run",
})
ve := requireValidation(t, err, "--styles has 3 issues")
for _, want := range []string{
"border_styles.horizontal is not a valid side",
`row_sizes[0].type "custom" is invalid`,
"col_sizes[0].type pixel requires size",
} {
if !strings.Contains(ve.Message, want) {
t.Errorf("aggregated message should contain %q, got %q", want, ve.Message)
}
}
// D2: each type/size error inlines a complete valid op.
if !strings.Contains(ve.Message, `{"range":"2:10","type":"pixel","size":32}`) {
t.Errorf("row_sizes error should inline a full valid example, got %q", ve.Message)
}
if !strings.Contains(ve.Message, `{"range":"A:C","type":"pixel","size":120}`) {
t.Errorf("col_sizes error should inline a full valid example, got %q", ve.Message)
}
}
// TestTablePut_StylesBorderAllExpands verifies the "all" shorthand is
// rewritten to four explicit sides instead of being rejected (or worse,
// passed through for the server to reject, as happened on the typed-cells
// path in eval V2U013/V2U021).
func TestTablePut_StylesBorderAllExpands(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+table-put")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"s","columns":["a"],"data":[["x"]]}]}`,
"--styles", `{"styles":[{"name":"s","cell_styles":[{"range":"A1:A1","border_styles":{"all":{"style":"solid","weight":"thin"}}}]}]}`,
"--dry-run",
})
if err != nil {
t.Fatalf("border all should expand to four sides and pass, got: %v", err)
}
// table-put's dry-run body carries the tool input as an escaped JSON
// string, so match the escaped key form.
for _, side := range []string{`\"top\"`, `\"bottom\"`, `\"left\"`, `\"right\"`} {
if !strings.Contains(stdout, side) {
t.Errorf("dry-run body should carry expanded side %s, got %q", side, stdout)
}
}
if strings.Contains(stdout, `\"all\"`) {
t.Errorf("dry-run body must not carry the raw all shorthand, got %q", stdout)
}
}
// TestCellsSet_BorderAllAndMisNestedBorder covers the typed --cells path:
// the "all" shorthand expands CLI-side, and border_styles mis-nested inside
// cell_styles is intercepted with a move-it prescription instead of a
// server-side 900015206.
func TestCellsSet_BorderAllAndMisNestedBorder(t *testing.T) {
t.Parallel()
t.Run("border all expands", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1",
"--cells", `[[{"value":"x","border_styles":{"all":{"style":"solid"}}}]]`,
"--dry-run",
})
if err != nil {
t.Fatalf("border all should expand and pass, got: %v", err)
}
if strings.Contains(stdout, `"all"`) || !strings.Contains(stdout, `"top"`) {
t.Errorf("dry-run body should carry expanded sides, got %q", stdout)
}
})
t.Run("mis-nested border_styles intercepted", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1",
"--cells", `[[{"value":"x","cell_styles":{"font_weight":"bold","border_styles":{"top":{"style":"solid"}}}}]]`,
"--dry-run",
})
ve := requireValidation(t, err, "cell_styles.border_styles is not valid")
if !strings.Contains(ve.Message, "sibling of cell_styles") {
t.Errorf("message should prescribe moving it up one level, got %q", ve.Message)
}
})
}
// TestCellsSetStyle_BorderAllExpands covers the --border-styles flag path
// (+cells-set-style / +cells-batch-set-style go through borderStylesFromFlag,
// not the typed --cells or --styles walkers): the "all" shorthand must expand
// CLI-side here too, or the backend rejects {"all":…}.
func TestCellsSetStyle_BorderAllExpands(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",
"--border-styles", `{"all":{"style":"solid","weight":"thin"}}`,
"--dry-run",
})
if err != nil {
t.Fatalf("border all should expand to four sides and pass, got: %v", err)
}
for _, side := range []string{`"top"`, `"bottom"`, `"left"`, `"right"`} {
if !strings.Contains(stdout, side) {
t.Errorf("dry-run body should carry expanded side %s, got %q", side, stdout)
}
}
if strings.Contains(stdout, `"all"`) {
t.Errorf("dry-run body must not carry the raw all shorthand, got %q", stdout)
}
}
// TestCellsMerge_RawAPIVocabularyNormalizes pins MERGE_ALL → all (the raw
// OpenAPI enum agents copy from Lark API docs) via the enum alias table.
func TestCellsMerge_RawAPIVocabularyNormalizes(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-merge")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1:B2",
"--merge-type", "MERGE_ALL",
"--dry-run",
})
if err != nil {
t.Fatalf("MERGE_ALL should normalize to all and pass, got: %v", err)
}
if !strings.Contains(stdout, `"all"`) {
t.Errorf("dry-run body should carry the normalized merge type, got %q", stdout)
}
}
// TestCellsSetStyle_WordWrapBooleanNormalizes pins --word-wrap true →
// auto-wrap (eval V2U029).
func TestCellsSetStyle_WordWrapBooleanNormalizes(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",
"--word-wrap", "true",
"--dry-run",
})
if err != nil {
t.Fatalf("--word-wrap true should normalize to auto-wrap, got: %v", err)
}
if !strings.Contains(stdout, "auto-wrap") {
t.Errorf("dry-run body should carry auto-wrap, got %q", stdout)
}
}
// TestUnderscoreFlagFormsParse pins the wire-vocabulary underscore rewrite:
// --sheet_name / --border_styles parse as their hyphen forms (agents copy
// field names out of JSON payloads where underscores are canonical).
func TestUnderscoreFlagFormsParse(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",
"--font_weight", "bold",
"--dry-run",
})
if err != nil {
t.Fatalf("underscore flag forms should parse as hyphen forms, got: %v", err)
}
if !strings.Contains(stdout, "bold") {
t.Errorf("dry-run body should carry the style, got %q", stdout)
}
}
// TestPrintFlagSchema_UnderscoreFlagName pins --flag-name border_styles
// resolving the border-styles schema (eval V2U013 burned a retry on this).
func TestPrintFlagSchema_UnderscoreFlagName(t *testing.T) {
t.Parallel()
print := printFlagSchemaFor("+cells-set-style")
out, err := print("border_styles")
if err != nil {
t.Fatalf("underscore flag-name should resolve the hyphen schema, got: %v", err)
}
if len(out) == 0 {
t.Fatal("expected schema output")
}
}
// TestPrintFlagSchema_DottedPathSlices pins the schema sub-path slicing
// contract on the real embedded chart schema: a dotted --flag-name returns
// just that subtree, and a path miss lists the keys actually available.
func TestPrintFlagSchema_DottedPathSlices(t *testing.T) {
t.Parallel()
print := printFlagSchemaFor("+chart-create")
t.Run("slices a nested subtree", func(t *testing.T) {
t.Parallel()
out, err := print("properties.snapshot.plotArea.axes")
if err != nil {
t.Fatalf("dotted path should slice, got: %v", err)
}
full, err2 := print("properties")
if err2 != nil {
t.Fatalf("full dump: %v", err2)
}
if len(out) == 0 || len(out) >= len(full) {
t.Errorf("slice should be non-empty and smaller than the full schema (%d vs %d bytes)", len(out), len(full))
}
})
t.Run("path miss lists available keys", func(t *testing.T) {
t.Parallel()
_, err := print("properties.snapshot.nosuchkey")
if err == nil {
t.Fatal("expected error for unknown path segment")
}
if !strings.Contains(err.Error(), "available keys:") {
t.Errorf("error should list available keys, got %v", err)
}
})
}

View File

@@ -79,27 +79,42 @@ func fetchMeetingDetail(ctx context.Context, runtime *common.RuntimeContext, mee
result.NoteID = v
}
// Step 2: query minute_token via recording API
minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
if minuteErr != nil {
// Recording API failed — surface the error but keep data from step 1
result.Error = fmt.Sprintf("failed to query minutes: %v", minuteErr)
minuteHint = ""
}
if minuteToken != "" {
result.MinuteToken = minuteToken
// Step 2: query minute_token via recording API — only meaningful once the
// meeting has ended. While it is still in progress the note/minute are not
// generated yet, so skip the recording call and surface an informational
// hint instead of letting an unclassified recording error fail the command.
inProgress := meetingInProgress(meeting)
var minuteHint string
if inProgress {
minuteHint = "meeting is still in progress; note and minute are not generated yet"
} else {
minuteToken, hint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
minuteHint = hint
if minuteErr != nil {
// Recording lookup is a best-effort supplement; step 1 already
// succeeded, so degrade the failure to a hint rather than failing
// the whole command.
minuteHint = fmt.Sprintf("failed to query minutes: %v", minuteErr)
}
if minuteToken != "" {
result.MinuteToken = minuteToken
}
}
// Add hints for empty resources (not errors, just informational)
var emptyFields []string
if result.NoteID == "" {
emptyFields = append(emptyFields, "note_id")
}
if result.MinuteToken == "" && minuteErr == nil && minuteHint == "" {
emptyFields = append(emptyFields, "minute_token")
}
if len(emptyFields) > 0 {
result.Hint = fmt.Sprintf("%s not found for this meeting", strings.Join(emptyFields, ", "))
// Add hints for empty resources (not errors, just informational). For an
// in-progress meeting the "not found" wording is noise, so we only emit the
// single in-progress hint below.
if !inProgress {
var emptyFields []string
if result.NoteID == "" {
emptyFields = append(emptyFields, "note_id")
}
if result.MinuteToken == "" && minuteHint == "" {
emptyFields = append(emptyFields, "minute_token")
}
if len(emptyFields) > 0 {
result.Hint = fmt.Sprintf("%s not found for this meeting", strings.Join(emptyFields, ", "))
}
}
if minuteHint != "" {
if result.Hint != "" {
@@ -112,6 +127,36 @@ func fetchMeetingDetail(ctx context.Context, runtime *common.RuntimeContext, mee
return result
}
// meetingTimeField reads a meeting time field as a string regardless of whether
// the API returned it as a JSON string or number. VC serializes int64
// timestamps as strings, but coercing via %v keeps parsing robust either way;
// float64(0) renders as "0", which parseFlexibleTime treats as "absent".
func meetingTimeField(meeting map[string]any, key string) string {
v, ok := meeting[key]
if !ok || v == nil {
return ""
}
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
// meetingInProgress reports whether a meeting is still ongoing, using the same
// start/end heuristic as +meeting-events (meetingEventsMeetingFromPayload): a
// meeting is ongoing when it has a start time but no end time, or its end time
// is not after its start time. It reads the RAW timestamp fields, not the
// FormatTime-rendered result strings, because parseFlexibleTime only accepts
// Unix timestamps or RFC3339. Empty or "0" values are treated as absent.
func meetingInProgress(meeting map[string]any) bool {
start, hasStart := parseFlexibleTime(meetingTimeField(meeting, "start_time"))
end, hasEnd := parseFlexibleTime(meetingTimeField(meeting, "end_time"))
if !hasStart {
return false
}
if !hasEnd {
return true
}
return !end.After(start)
}
// VCDetail gets meeting details including note_id and minute_token.
var VCDetail = common.Shortcut{
Service: "vc",

View File

@@ -269,11 +269,58 @@ func TestFetchMeetingDetail_RecordingAPIErrorButNoteOK(t *testing.T) {
if result.MinuteToken != "" {
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
}
if !strings.Contains(result.Error, "failed to query minutes") || !strings.Contains(result.Error, "weird API error") {
t.Errorf("error = %q, want contains 'failed to query minutes' and 'weird API error'", result.Error)
if result.Error != "" {
t.Errorf("error = %q, want empty: a recording lookup failure must not fail the command", result.Error)
}
if strings.Contains(result.Hint, "minute_token") {
t.Errorf("hint = %q, should not mention minute_token when there is an error", result.Hint)
if !strings.Contains(result.Hint, "failed to query minutes") || !strings.Contains(result.Hint, "weird API error") {
t.Errorf("hint = %q, want contains 'failed to query minutes' and 'weird API error'", result.Hint)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// TestFetchMeetingDetail_MeetingInProgress pins the in-progress behavior: when a
// meeting is still ongoing (end_time not after start_time), +detail must not
// call the recording API at all — it returns meeting metadata with an
// informational hint and no error. Deliberately register NO recording stub so
// that any recording call would fail on an unmatched request.
func TestFetchMeetingDetail_MeetingInProgress(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m_live",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"meeting": map[string]interface{}{
"id": "m_live",
"topic": "Live Meeting",
"meeting_no": "912052453",
// end_time == start_time signals an ongoing meeting.
"start_time": "1752000000",
"end_time": "1752000000",
}},
},
})
if err := botExec(t, "detail-live", f, func(_ context.Context, rctx *common.RuntimeContext) error {
result := fetchMeetingDetail(context.Background(), rctx, "m_live")
if result.Topic != "Live Meeting" {
t.Errorf("topic = %q, want 'Live Meeting'", result.Topic)
}
if result.Error != "" {
t.Errorf("error = %q, want empty for an in-progress meeting", result.Error)
}
if result.MinuteToken != "" {
t.Errorf("minute_token = %q, want empty for an in-progress meeting", result.MinuteToken)
}
if !strings.Contains(result.Hint, "in progress") {
t.Errorf("hint = %q, want to mention the meeting is in progress", result.Hint)
}
if strings.Contains(result.Hint, "not found for this meeting") {
t.Errorf("hint = %q, should not emit not-found noise for an in-progress meeting", result.Hint)
}
return nil
}); err != nil {

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
WikiMove,
WikiMoveToDrive,
WikiNodeCreate,
WikiDeleteSpace,
WikiSpaceList,

View File

@@ -0,0 +1,415 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
// These are fixed backend wire values. Keep them unchanged even though the
// shortcut and its continuation scenario use the user-facing Drive name.
wikiMoveToDriveTaskType = "move_wiki_to_docs"
wikiMoveToDriveResult = "move_wiki_to_docs_result"
wikiMoveToDriveStatusSuccess = 0
wikiMoveToDriveStatusProcessing = 1
wikiMoveToDriveStatusFailure = -1
)
var (
wikiMoveToDrivePollAttempts = 30
wikiMoveToDrivePollInterval = 2 * time.Second
)
// WikiMoveToDrive moves a Wiki node out of its knowledge space and into a
// Drive folder. The API always creates an async task, so the shortcut polls the
// Wiki task endpoint and returns a resumable command when the bounded window
// expires.
var WikiMoveToDrive = common.Shortcut{
Service: "wiki",
Command: "+move-to-drive",
Description: "Move a wiki node to a Drive folder, polling the async task until it finishes",
Risk: "write",
// The move endpoint's wiki:wiki / wiki:node:move /
// space:document:move list is an OR-set, while Shortcut.Scopes is an
// ALL-required preflight. Use the registry's highest-priority candidate
// plus the read scope required by the task-status endpoint.
Scopes: []string{"space:document:move", "wiki:space:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "node-token", Desc: "wiki node_token to move out of the knowledge space", Required: true},
{Name: "folder-token", Desc: "target Drive folder token; omit to move to the calling identity's personal-space root"},
},
Tips: []string{
"The source must be a wiki node_token, not the backing document's obj_token; use wiki +node-get when unsure.",
"Omit --folder-token to move the document to the calling identity's personal-space root.",
"Moving out of Wiki removes the node from the Wiki tree and replaces inherited Wiki permissions with the target Drive folder's permission model.",
"The move is asynchronous; if the bounded poll times out, continue with the next_command returned in the output.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateWikiMoveToDriveSpec(readWikiMoveToDriveSpec(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return buildWikiMoveToDriveDryRun(readWikiMoveToDriveSpec(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := readWikiMoveToDriveSpec(runtime)
out, err := runWikiMoveToDrive(ctx, wikiMoveToDriveAPI{runtime: runtime}, runtime, spec)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
type wikiMoveToDriveSpec struct {
NodeToken string
FolderToken string
}
func (spec wikiMoveToDriveSpec) RequestBody() map[string]interface{} {
body := map[string]interface{}{}
if spec.FolderToken != "" {
body["folder_token"] = spec.FolderToken
}
return body
}
type wikiMoveToDriveTaskStatus struct {
TaskID string
Status int
StatusMsg string
ObjToken string
ObjType string
URL string
}
func (s wikiMoveToDriveTaskStatus) Ready() bool {
return s.Status == wikiMoveToDriveStatusSuccess
}
func (s wikiMoveToDriveTaskStatus) Failed() bool {
return s.Status < wikiMoveToDriveStatusSuccess
}
func (s wikiMoveToDriveTaskStatus) StatusLabel() string {
if label := strings.TrimSpace(s.StatusMsg); label != "" {
return label
}
switch {
case s.Ready():
return "success"
case s.Failed():
return "failure"
default:
return "processing"
}
}
type wikiMoveToDriveClient interface {
MoveWikiToDrive(ctx context.Context, spec wikiMoveToDriveSpec) (string, error)
GetMoveWikiToDriveTask(ctx context.Context, taskID string) (wikiMoveToDriveTaskStatus, error)
}
type wikiMoveToDriveAPI struct {
runtime *common.RuntimeContext
}
func (api wikiMoveToDriveAPI) MoveWikiToDrive(ctx context.Context, spec wikiMoveToDriveSpec) (string, error) {
data, err := api.runtime.CallAPITyped(
"POST",
fmt.Sprintf(
"/open-apis/wiki/v2/nodes/%s/move_wiki_to_docs",
validate.EncodePathSegment(spec.NodeToken),
),
nil,
spec.RequestBody(),
)
if err != nil {
return "", err
}
taskID := common.GetString(data, "task_id")
if taskID == "" {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki move-to-drive response missing task_id")
}
return taskID, nil
}
func (api wikiMoveToDriveAPI) GetMoveWikiToDriveTask(ctx context.Context, taskID string) (wikiMoveToDriveTaskStatus, error) {
data, err := api.runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": wikiMoveToDriveTaskType},
nil,
)
if err != nil {
return wikiMoveToDriveTaskStatus{}, err
}
return parseWikiMoveToDriveTaskStatus(taskID, common.GetMap(data, "task"))
}
func readWikiMoveToDriveSpec(runtime *common.RuntimeContext) wikiMoveToDriveSpec {
return wikiMoveToDriveSpec{
NodeToken: strings.TrimSpace(runtime.Str("node-token")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
}
}
func validateWikiMoveToDriveSpec(spec wikiMoveToDriveSpec) error {
if spec.NodeToken == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token is required").WithParam("--node-token")
}
if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil {
return err
}
return validateOptionalResourceName(spec.FolderToken, "--folder-token")
}
func buildWikiMoveToDriveDryRun(spec wikiMoveToDriveSpec) *common.DryRunAPI {
dry := common.NewDryRunAPI().Desc(
"2-step orchestration: move wiki node to Drive -> poll wiki move-to-drive task result",
)
dry.POST(fmt.Sprintf(
"/open-apis/wiki/v2/nodes/%s/move_wiki_to_docs",
validate.EncodePathSegment(spec.NodeToken),
)).
Desc("[1] Move wiki node to Drive").
Body(spec.RequestBody())
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[2] Poll wiki move-to-drive task result").
Set("task_id", "<task_id>").
Params(map[string]interface{}{"task_type": wikiMoveToDriveTaskType})
return dry
}
func runWikiMoveToDrive(
ctx context.Context,
client wikiMoveToDriveClient,
runtime *common.RuntimeContext,
spec wikiMoveToDriveSpec,
) (map[string]interface{}, error) {
folderLabel := "personal-space root"
if spec.FolderToken != "" {
folderLabel = common.MaskToken(spec.FolderToken)
}
fmt.Fprintf(
runtime.IO().ErrOut,
"Moving wiki node %s to Drive folder %s...\n",
common.MaskToken(spec.NodeToken),
folderLabel,
)
taskID, err := client.MoveWikiToDrive(ctx, spec)
if err != nil {
return nil, err
}
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move-to-drive is async, polling task %s...\n", taskID)
status, ready, err := pollWikiMoveToDriveTask(ctx, client, runtime, taskID)
if err != nil {
return nil, err
}
out := map[string]interface{}{
"node_token": spec.NodeToken,
"folder_token": spec.FolderToken,
"task_id": taskID,
"ready": ready,
"failed": status.Failed(),
"status": status.Status,
"status_msg": status.StatusLabel(),
"obj_token": status.ObjToken,
"obj_type": status.ObjType,
"url": status.URL,
}
if !ready {
nextCommand := wikiMoveToDriveTaskResultCommand(taskID, runtime.As(), wikiMoveToDriveProfileName(runtime))
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move-to-drive task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
return out, nil
}
func pollWikiMoveToDriveTask(
ctx context.Context,
client wikiMoveToDriveClient,
runtime *common.RuntimeContext,
taskID string,
) (wikiMoveToDriveTaskStatus, bool, error) {
lastStatus := wikiMoveToDriveTaskStatus{
TaskID: taskID,
Status: wikiMoveToDriveStatusProcessing,
}
var lastErr error
hadSuccessfulPoll := false
for attempt := 1; attempt <= wikiMoveToDrivePollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return lastStatus, false, wrapWikiMoveToDrivePollContextError(
ctx.Err(), taskID, runtime.As(), wikiMoveToDriveProfileName(runtime),
)
case <-time.After(wikiMoveToDrivePollInterval):
}
}
status, err := client.GetMoveWikiToDriveTask(ctx, taskID)
if err != nil {
if contextErr := ctx.Err(); contextErr != nil {
return lastStatus, false, wrapWikiMoveToDrivePollContextError(
contextErr, taskID, runtime.As(), wikiMoveToDriveProfileName(runtime),
)
}
lastErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move-to-drive status attempt %d/%d failed: %v\n", attempt, wikiMoveToDrivePollAttempts, err)
continue
}
lastStatus = status
hadSuccessfulPoll = true
if status.Ready() {
fmt.Fprintln(runtime.IO().ErrOut, "Wiki move-to-drive task completed successfully.")
return status, true, nil
}
if status.Failed() {
return status, false, errs.NewAPIError(
errs.SubtypeServerError,
"wiki move-to-drive task %s failed: %s",
taskID,
status.StatusLabel(),
)
}
fmt.Fprintf(
runtime.IO().ErrOut,
"Wiki move-to-drive status %d/%d: %s\n",
attempt,
wikiMoveToDrivePollAttempts,
status.StatusLabel(),
)
}
if err := ctx.Err(); err != nil {
return lastStatus, false, wrapWikiMoveToDrivePollContextError(
err, taskID, runtime.As(), wikiMoveToDriveProfileName(runtime),
)
}
if !hadSuccessfulPoll && lastErr != nil {
hint := fmt.Sprintf(
"the wiki move-to-drive task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
taskID,
wikiMoveToDriveTaskResultCommand(taskID, runtime.As(), wikiMoveToDriveProfileName(runtime)),
)
if _, ok := errs.ProblemOf(lastErr); ok {
return lastStatus, false, appendWikiProblemHint(lastErr, hint)
}
return lastStatus, false, errs.NewInternalError(errs.SubtypeUnknown, "%s", lastErr.Error()).
WithHint("%s", hint).
WithCause(lastErr)
}
return lastStatus, false, nil
}
func parseWikiMoveToDriveTaskStatus(taskID string, task map[string]interface{}) (wikiMoveToDriveTaskStatus, error) {
if task == nil {
return wikiMoveToDriveTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
}
result := common.GetMap(task, wikiMoveToDriveResult)
if result == nil {
return wikiMoveToDriveTaskStatus{}, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"wiki task response missing %s",
wikiMoveToDriveResult,
)
}
statusCode, ok := common.GetFloatOK(result, "status")
if !ok {
return wikiMoveToDriveTaskStatus{}, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"wiki task response has missing or non-numeric %s.status",
wikiMoveToDriveResult,
)
}
if statusCode != wikiMoveToDriveStatusFailure &&
statusCode != wikiMoveToDriveStatusSuccess &&
statusCode != wikiMoveToDriveStatusProcessing {
return wikiMoveToDriveTaskStatus{}, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"wiki task response has unsupported %s.status: %v",
wikiMoveToDriveResult,
statusCode,
)
}
status := wikiMoveToDriveTaskStatus{
TaskID: common.GetString(task, "task_id"),
Status: int(statusCode),
StatusMsg: common.GetString(result, "status_msg"),
ObjToken: common.GetString(result, "obj_token"),
ObjType: common.GetString(result, "obj_type"),
URL: common.GetString(result, "url"),
}
if status.TaskID == "" {
status.TaskID = taskID
}
return status, nil
}
// Preserve the originating identity and profile so a resumed status lookup
// uses the same credential context that created the async task.
func wikiMoveToDriveTaskResultCommand(taskID string, identity core.Identity, profileName string) string {
asFlag := string(identity)
if asFlag == "" {
asFlag = "user"
}
profileFlag := ""
if profileName != "" {
profileFlag = fmt.Sprintf(" --profile %s", profileName)
}
return fmt.Sprintf(
"lark-cli%s drive +task_result --scenario wiki_move_to_drive --task-id %s --as %s",
profileFlag,
taskID,
asFlag,
)
}
func wikiMoveToDriveProfileName(runtime *common.RuntimeContext) string {
if runtime == nil || runtime.Config == nil {
return ""
}
return runtime.Config.ProfileName
}
func wrapWikiMoveToDrivePollContextError(err error, taskID string, identity core.Identity, profileName string) error {
if err == nil {
return nil
}
subtype := errs.SubtypeNetworkTransport
message := "wiki move-to-drive task polling cancelled: %s"
if errors.Is(err, context.DeadlineExceeded) {
subtype = errs.SubtypeNetworkTimeout
message = "wiki move-to-drive task polling deadline exceeded: %s"
}
return errs.NewNetworkError(subtype, message, err).
WithHint("the task may still be running; retry status lookup with: %s", wikiMoveToDriveTaskResultCommand(taskID, identity, profileName)).
WithCause(err)
}

View File

@@ -0,0 +1,481 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"bytes"
"context"
"errors"
"net/http"
"reflect"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
type fakeWikiMoveToDriveClient struct {
moveTaskID string
moveErr error
taskStatus []wikiMoveToDriveTaskStatus
taskErrs []error
taskHooks []func()
moveSpecs []wikiMoveToDriveSpec
taskCalls []string
}
func (fake *fakeWikiMoveToDriveClient) MoveWikiToDrive(ctx context.Context, spec wikiMoveToDriveSpec) (string, error) {
fake.moveSpecs = append(fake.moveSpecs, spec)
if fake.moveErr != nil {
return "", fake.moveErr
}
return fake.moveTaskID, nil
}
func (fake *fakeWikiMoveToDriveClient) GetMoveWikiToDriveTask(ctx context.Context, taskID string) (wikiMoveToDriveTaskStatus, error) {
idx := len(fake.taskCalls)
fake.taskCalls = append(fake.taskCalls, taskID)
if idx < len(fake.taskHooks) && fake.taskHooks[idx] != nil {
fake.taskHooks[idx]()
}
if idx < len(fake.taskErrs) && fake.taskErrs[idx] != nil {
return wikiMoveToDriveTaskStatus{TaskID: taskID, Status: wikiMoveToDriveStatusProcessing}, fake.taskErrs[idx]
}
if idx < len(fake.taskStatus) {
status := fake.taskStatus[idx]
if status.TaskID == "" {
status.TaskID = taskID
}
return status, nil
}
return wikiMoveToDriveTaskStatus{TaskID: taskID, Status: wikiMoveToDriveStatusProcessing}, nil
}
var wikiMoveToDrivePollMu sync.Mutex
func withSingleWikiMoveToDrivePoll(t *testing.T) {
withWikiMoveToDrivePoll(t, 1)
}
func withWikiMoveToDrivePoll(t *testing.T, attempts int) {
t.Helper()
wikiMoveToDrivePollMu.Lock()
previousAttempts, previousInterval := wikiMoveToDrivePollAttempts, wikiMoveToDrivePollInterval
wikiMoveToDrivePollAttempts, wikiMoveToDrivePollInterval = attempts, 0
t.Cleanup(func() {
wikiMoveToDrivePollAttempts, wikiMoveToDrivePollInterval = previousAttempts, previousInterval
wikiMoveToDrivePollMu.Unlock()
})
}
func newWikiMoveToDriveRuntime(t *testing.T, identity core.Identity) (*common.RuntimeContext, *bytes.Buffer) {
t.Helper()
cfg := wikiTestConfig()
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
runtime := common.TestNewRuntimeContextWithIdentity(nil, cfg, identity)
runtime.Factory = factory
return runtime, stderr
}
func TestWikiMoveToDriveDeclaredContract(t *testing.T) {
t.Parallel()
wantScopes := []string{"space:document:move", "wiki:space:read"}
if !reflect.DeepEqual(WikiMoveToDrive.Scopes, wantScopes) {
t.Fatalf("WikiMoveToDrive.Scopes = %v, want %v", WikiMoveToDrive.Scopes, wantScopes)
}
if WikiMoveToDrive.Risk != "write" {
t.Fatalf("WikiMoveToDrive.Risk = %q, want write", WikiMoveToDrive.Risk)
}
}
func TestValidateWikiMoveToDriveSpec(t *testing.T) {
t.Parallel()
t.Run("requires node token", func(t *testing.T) {
err := validateWikiMoveToDriveSpec(wikiMoveToDriveSpec{})
requireWikiValidationParams(t, err, "--node-token")
})
t.Run("rejects unsafe folder token", func(t *testing.T) {
err := validateWikiMoveToDriveSpec(wikiMoveToDriveSpec{
NodeToken: "wikcnABC",
FolderToken: "../folder",
})
requireWikiValidationParams(t, err, "--folder-token")
cause := errors.Unwrap(err)
if cause == nil || !errors.Is(err, cause) {
t.Fatal("validation error must preserve its path-validation cause")
}
})
t.Run("accepts optional folder", func(t *testing.T) {
err := validateWikiMoveToDriveSpec(wikiMoveToDriveSpec{NodeToken: "wikcnABC"})
if err != nil {
t.Fatalf("validateWikiMoveToDriveSpec() error = %v", err)
}
})
}
func TestWikiMoveToDriveRequestBodyOmitsEmptyFolder(t *testing.T) {
t.Parallel()
withoutFolder := (wikiMoveToDriveSpec{NodeToken: "wikcnABC"}).RequestBody()
if _, exists := withoutFolder["folder_token"]; exists {
t.Fatalf("empty folder_token must be omitted, got %#v", withoutFolder)
}
withFolder := (wikiMoveToDriveSpec{NodeToken: "wikcnABC", FolderToken: "fldABC"}).RequestBody()
if withFolder["folder_token"] != "fldABC" {
t.Fatalf("RequestBody() = %#v, want folder_token=fldABC", withFolder)
}
}
func TestBuildWikiMoveToDriveDryRun(t *testing.T) {
t.Parallel()
steps := decodeDryRunAPIs(t, buildWikiMoveToDriveDryRun(wikiMoveToDriveSpec{
NodeToken: "wikcnABC",
FolderToken: "fldABC",
}))
if len(steps) != 2 {
t.Fatalf("len(api) = %d, want 2", len(steps))
}
if steps[0].Method != "POST" || steps[0].URL != "/open-apis/wiki/v2/nodes/wikcnABC/move_wiki_to_docs" {
t.Fatalf("POST step = %#v", steps[0])
}
if steps[0].Body["folder_token"] != "fldABC" {
t.Fatalf("POST body = %#v", steps[0].Body)
}
if steps[1].Method != "GET" || steps[1].URL != "/open-apis/wiki/v2/tasks/%3Ctask_id%3E" {
t.Fatalf("GET step = %#v", steps[1])
}
if steps[1].Params["task_type"] != wikiMoveToDriveTaskType {
t.Fatalf("task params = %#v", steps[1].Params)
}
}
func TestParseWikiMoveToDriveTaskStatus(t *testing.T) {
t.Parallel()
t.Run("success with task id fallback and result fields", func(t *testing.T) {
status, err := parseWikiMoveToDriveTaskStatus("signed-task-id", map[string]interface{}{
"move_wiki_to_docs_result": map[string]interface{}{
"status": float64(0),
"status_msg": "success",
"obj_token": "docxABC",
"obj_type": "docx",
"url": "https://example.feishu.cn/docx/docxABC",
},
})
if err != nil {
t.Fatalf("parseWikiMoveToDriveTaskStatus() error = %v", err)
}
if status.TaskID != "signed-task-id" || !status.Ready() || status.Failed() {
t.Fatalf("status = %+v", status)
}
if status.ObjToken != "docxABC" || status.ObjType != "docx" || status.URL == "" {
t.Fatalf("result fields = %+v", status)
}
})
t.Run("rejects missing dedicated result", func(t *testing.T) {
_, err := parseWikiMoveToDriveTaskStatus("task", map[string]interface{}{})
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
}
})
t.Run("rejects missing status", func(t *testing.T) {
_, err := parseWikiMoveToDriveTaskStatus("task", map[string]interface{}{
"move_wiki_to_docs_result": map[string]interface{}{},
})
problem, ok := errs.ProblemOf(err)
if !ok || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("error = %T %v, want invalid_response", err, err)
}
})
for name, rawStatus := range map[string]interface{}{
"null": nil,
"string": "processing",
"fractional": 0.5,
"unknown value": 2,
} {
t.Run("rejects "+name+" status", func(t *testing.T) {
_, err := parseWikiMoveToDriveTaskStatus("task", map[string]interface{}{
"move_wiki_to_docs_result": map[string]interface{}{"status": rawStatus},
})
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
}
})
}
}
func TestRunWikiMoveToDriveSuccess(t *testing.T) {
withSingleWikiMoveToDrivePoll(t)
runtime, stderr := newWikiMoveToDriveRuntime(t, core.AsUser)
client := &fakeWikiMoveToDriveClient{
moveTaskID: "raw-task-signature",
taskStatus: []wikiMoveToDriveTaskStatus{{
Status: wikiMoveToDriveStatusSuccess,
StatusMsg: "success",
ObjToken: "docxABC",
ObjType: "docx",
URL: "https://example.feishu.cn/docx/docxABC",
}},
}
out, err := runWikiMoveToDrive(context.Background(), client, runtime, wikiMoveToDriveSpec{
NodeToken: "wikcnABC",
FolderToken: "fldABC",
})
if err != nil {
t.Fatalf("runWikiMoveToDrive() error = %v", err)
}
if out["task_id"] != "raw-task-signature" || out["ready"] != true || out["failed"] != false {
t.Fatalf("output = %#v", out)
}
if out["obj_token"] != "docxABC" || out["obj_type"] != "docx" || out["url"] == "" {
t.Fatalf("output result fields = %#v", out)
}
if len(client.moveSpecs) != 1 || client.moveSpecs[0].FolderToken != "fldABC" {
t.Fatalf("move specs = %#v", client.moveSpecs)
}
if !strings.Contains(stderr.String(), "completed successfully") {
t.Fatalf("stderr = %q", stderr.String())
}
}
func TestRunWikiMoveToDriveTimeoutReturnsResumeCommand(t *testing.T) {
withSingleWikiMoveToDrivePoll(t)
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsBot)
runtime.Config.ProfileName = "secondary"
client := &fakeWikiMoveToDriveClient{
moveTaskID: "raw-task-signature",
taskStatus: []wikiMoveToDriveTaskStatus{{Status: wikiMoveToDriveStatusProcessing}},
}
out, err := runWikiMoveToDrive(context.Background(), client, runtime, wikiMoveToDriveSpec{NodeToken: "wikcnABC"})
if err != nil {
t.Fatalf("runWikiMoveToDrive() error = %v", err)
}
if out["ready"] != false || out["failed"] != false || out["timed_out"] != true {
t.Fatalf("timeout output = %#v", out)
}
nextCommand, _ := out["next_command"].(string)
if !strings.HasPrefix(nextCommand, "lark-cli --profile secondary drive +task_result") ||
!strings.Contains(nextCommand, "--scenario wiki_move_to_drive") ||
!strings.Contains(nextCommand, "--as bot") {
t.Fatalf("next_command = %q", nextCommand)
}
}
func TestPollWikiMoveToDriveContinuesFromProcessingToSuccess(t *testing.T) {
withWikiMoveToDrivePoll(t, 2)
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsUser)
client := &fakeWikiMoveToDriveClient{
taskStatus: []wikiMoveToDriveTaskStatus{
{Status: wikiMoveToDriveStatusProcessing},
{Status: wikiMoveToDriveStatusSuccess, ObjToken: "docxABC"},
},
}
status, ready, err := pollWikiMoveToDriveTask(context.Background(), client, runtime, "signed-task-id")
if err != nil || !ready || !status.Ready() || status.ObjToken != "docxABC" {
t.Fatalf("status=%+v ready=%t err=%v", status, ready, err)
}
if len(client.taskCalls) != 2 {
t.Fatalf("task calls = %v, want two attempts", client.taskCalls)
}
}
func TestPollWikiMoveToDriveRecoversFromTransientError(t *testing.T) {
withWikiMoveToDrivePoll(t, 2)
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsUser)
requestTimeout := errs.NewNetworkError(errs.SubtypeNetworkTimeout, "temporary request timeout").
WithCause(context.DeadlineExceeded)
client := &fakeWikiMoveToDriveClient{
taskErrs: []error{requestTimeout},
taskStatus: []wikiMoveToDriveTaskStatus{
{},
{Status: wikiMoveToDriveStatusSuccess, ObjToken: "docxABC"},
},
}
status, ready, err := pollWikiMoveToDriveTask(context.Background(), client, runtime, "signed-task-id")
if err != nil || !ready || !status.Ready() || status.ObjToken != "docxABC" {
t.Fatalf("status=%+v ready=%t err=%v", status, ready, err)
}
if len(client.taskCalls) != 2 {
t.Fatalf("task calls = %v, want two attempts", client.taskCalls)
}
}
func TestPollWikiMoveToDriveDoesNotSwallowFinalCancellation(t *testing.T) {
withWikiMoveToDrivePoll(t, 2)
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsUser)
ctx, cancel := context.WithCancel(context.Background())
client := &fakeWikiMoveToDriveClient{
taskStatus: []wikiMoveToDriveTaskStatus{{Status: wikiMoveToDriveStatusProcessing}},
taskErrs: []error{nil, context.Canceled},
taskHooks: []func(){nil, cancel},
}
_, ready, err := pollWikiMoveToDriveTask(ctx, client, runtime, "signed-task-id")
if ready || !errors.Is(err, context.Canceled) {
t.Fatalf("ready=%t err=%T %v, want preserved context cancellation", ready, err, err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("error = %T %v, want network/transport", err, err)
}
}
func TestRunWikiMoveToDriveFailureIsTypedAPIError(t *testing.T) {
withSingleWikiMoveToDrivePoll(t)
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsUser)
client := &fakeWikiMoveToDriveClient{
moveTaskID: "raw-task-signature",
taskStatus: []wikiMoveToDriveTaskStatus{{Status: wikiMoveToDriveStatusFailure, StatusMsg: "failure"}},
}
_, err := runWikiMoveToDrive(context.Background(), client, runtime, wikiMoveToDriveSpec{NodeToken: "wikcnABC"})
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryAPI || problem.Subtype != errs.SubtypeServerError {
t.Fatalf("error = %T %v, want api/server_error", err, err)
}
}
func TestPollWikiMoveToDrivePreservesTypedPollError(t *testing.T) {
withSingleWikiMoveToDrivePoll(t)
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsUser)
cause := errors.New("connection reset")
upstream := errs.NewNetworkError(errs.SubtypeNetworkTransport, "poll failed").
WithCode(503).
WithHint("retry upstream").
WithCause(cause)
client := &fakeWikiMoveToDriveClient{taskErrs: []error{upstream}}
_, ready, err := pollWikiMoveToDriveTask(context.Background(), client, runtime, "raw-task-signature")
if ready || err != upstream {
t.Fatalf("ready=%t err=%T %v, want original typed error", ready, err, err)
}
if !errors.Is(err, cause) {
t.Fatal("typed poll error must preserve its cause")
}
problem, _ := errs.ProblemOf(err)
if problem.Code != 503 || !strings.Contains(problem.Hint, "retry upstream") || !strings.Contains(problem.Hint, "wiki_move_to_drive") {
t.Fatalf("problem = %+v", problem)
}
}
func TestWrapWikiMoveToDrivePollContextError(t *testing.T) {
t.Parallel()
err := wrapWikiMoveToDrivePollContextError(context.DeadlineExceeded, "task-id", core.AsUser, "secondary")
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatal("wrapped deadline must preserve context.DeadlineExceeded")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTimeout {
t.Fatalf("error = %T %v, want network/timeout", err, err)
}
if !strings.Contains(problem.Hint, "wiki_move_to_drive") || !strings.Contains(problem.Hint, "--profile secondary") {
t.Fatalf("hint = %q", problem.Hint)
}
}
func TestWikiMoveToDriveExecuteCallsPostAndTaskEndpoint(t *testing.T) {
withSingleWikiMoveToDrivePoll(t)
factory, stdout, _, registry := cmdutil.TestFactory(t, wikiTestConfig())
moveStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/nodes/wikcnABC/move_wiki_to_docs",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "raw-task-signature"},
},
}
registry.Register(moveStub)
var taskQuery string
taskStub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/raw-task-signature",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
// The external handler currently omits task.task_id for this
// task type, so the CLI must preserve the signed request ID.
"move_wiki_to_docs_result": map[string]interface{}{
"status": 0,
"status_msg": "success",
"obj_token": "docxABC",
"obj_type": "docx",
"url": "https://example.feishu.cn/docx/docxABC",
},
},
},
},
}
taskStub.OnMatch = func(req *http.Request) { taskQuery = req.URL.RawQuery }
registry.Register(taskStub)
err := mountAndRunWiki(t, WikiMoveToDrive, []string{
"+move-to-drive",
"--node-token", "wikcnABC",
"--folder-token", "fldABC",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
body := decodeWikiCapturedJSONBody(t, moveStub)
if body["folder_token"] != "fldABC" {
t.Fatalf("captured POST body = %#v", body)
}
if !strings.Contains(taskQuery, "task_type=move_wiki_to_docs") {
t.Fatalf("task query = %q", taskQuery)
}
data := decodeWikiEnvelope(t, stdout)
if data["task_id"] != "raw-task-signature" || data["ready"] != true || data["obj_token"] != "docxABC" {
t.Fatalf("output = %#v", data)
}
}
func TestWikiMoveToDriveExecuteRejectsMissingTaskID(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, wikiTestConfig())
registry.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/nodes/wikcnABC/move_wiki_to_docs",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
err := mountAndRunWiki(t, WikiMoveToDrive, []string{
"+move-to-drive",
"--node-token", "wikcnABC",
"--as", "user",
}, factory, stdout)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
}
}

View File

@@ -18,6 +18,7 @@ import (
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
@@ -585,11 +586,15 @@ func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) {
}
func TestBuildAllowedHosts(t *testing.T) {
feishu := struct{ Open, Accounts, MCP string }{
"https://open.feishu.cn", "https://accounts.feishu.cn", "https://mcp.feishu.cn",
feishu := core.Endpoints{
Open: "https://open.feishu.cn",
Accounts: "https://accounts.feishu.cn",
MCP: "https://mcp.feishu.cn",
}
lark := struct{ Open, Accounts, MCP string }{
"https://open.larksuite.com", "https://accounts.larksuite.com", "https://mcp.larksuite.com",
lark := core.Endpoints{
Open: "https://open.larksuite.com",
Accounts: "https://accounts.larksuite.com",
MCP: "https://mcp.larksuite.com",
}
hosts := buildAllowedHosts(feishu, lark)
// feishu hosts

View File

@@ -3,6 +3,7 @@
## 快速决策
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:切到 `lark-wiki`,使用 `lark-cli wiki +move-to-drive`;不要把 Wiki token 直接交给 `drive +move`。执行前确认源节点与目标位置。
- 用户要把本地 `.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`

View File

@@ -7,6 +7,7 @@
## 快速决策
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow-knowledge-organize.md`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md),该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:使用 `wiki +move-to-drive`,不要使用 `wiki +move``drive +move`。执行前确认源节点与目标位置。
- 用户给的是知识库 URL`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`
- 用户要**删除**知识空间(`wiki +delete-space`)但只给了名称或 URL**不能**把名称 / URL 原样传给 `--space-id`,必须先解析出真实 `space_id`。解析方式:
- URL`.../wiki/<token>``lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}' --format json`,读 `data.node.space_id`
@@ -37,4 +38,4 @@
- `我的文档库` / `My Document Library` / `我的知识库` / `个人知识库` / `my_library` 都应视为 **Wiki personal library**,不是 Drive 根目录
- 处理这类目标时,先解析 `my_library` 对应的真实 `space_id`,再执行 `wiki +move``wiki +node-create` 或其他 Wiki 写操作
- 不要因为缺少显式 `space_id` 就退化成 `drive +move`
- 如果用户明确说的是 Drive 文件夹、云空间根目录、`我的空间`才进入 Drive 域处理
- 如果用户明确说的是 Drive 文件夹、云空间根目录、`我的空间`再按源对象分流:源对象是 Wiki 节点时用 `wiki +move-to-drive`,源对象已在 Drive 时用 `drive +move`

View File

@@ -69,19 +69,17 @@ lark-cli approval approvals get \
|---|---|---|
| `--data '{...}'` | 是 | 请求体,使用 JSON 传入 |
| `approval_code` | 是 | 审批定义 Code必须先通过 `approvals search` / `approvals get` 确认 |
| `form` | | 表单值,**JSON 数组字符串**,不是普通对象 |
| `form` | | 表单值,**JSON 数组字符串**,不是普通对象API 层非必填,但审批定义存在必填控件或用户需要提交表单值时必须传 |
| `node_approver_list` | 否 | 节点审批人列表;仅在定义要求补充审批人时传 |
| `node_cc_list` | 否 | 节点抄送人列表;仅在用户明确需要补充节点抄送人时传 |
| `uuid` | 否 | 幂等标识;重复重试同一请求时建议显式传入 |
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入 |
| `user_id_type` | 否 | 用户 ID 类型:`user_id``union_id``open_id`;涉及人员类 ID 时建议显式传 `open_id` |
| `--as user` | 否 | 建议显式指定用户身份;审批发起通常应使用用户身份 |
| `--yes` | 是 | 写操作确认;真实执行时必须显式传入 |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
### 4. 组装 `form`
`instances create --data.form` 是一个 JSON 数组字符串。组装原则:
`instances create --data.form`可选字段;传入时必须是一个 JSON 数组字符串。无表单或无需填写表单值的审批可省略 `form`,但只要审批定义包含需要提交的控件,就必须按控件结构组装后传入。组装原则:
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按本文中的创建参数规则与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
- 提交时必须至少保证每个控件的 `id``type``value` 符合当前接口要求;不要假设定义快照里出现的其他字段都能直接照搬。
@@ -173,7 +171,6 @@ lark-cli approval instances create \
}
]
}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
```

View File

@@ -14,6 +14,9 @@ lark-cli approval instances initiated --params '{"page_size":20}' --as user
# 只看某个审批定义下我发起的实例
lark-cli approval instances initiated --params '{"definition_code":"<DEFINITION_CODE>","page_size":20}' --as user
# 按发起时间范围筛选(秒级时间戳)
lark-cli approval instances initiated --params '{"start_timestamp":"<START_SECONDS>","end_timestamp":"<END_SECONDS>","page_size":20}' --as user
# 使用 page_token 翻页
lark-cli approval instances initiated --params '{"page_size":20,"page_token":"example_page_token"}' --as user
@@ -30,6 +33,8 @@ lark-cli approval instances initiated --params '{"page_size":20}' --as user --dr
|------|------|------|
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入;不传时使用默认分页与筛选 |
| `definition_code` | 否 | 审批定义 Code用于只查看某个审批定义下我发起的实例 |
| `start_timestamp` | 否 | 按发起时间筛选,时间范围开始值,秒级时间戳 |
| `end_timestamp` | 否 | 按发起时间筛选,时间范围结束值,秒级时间戳 |
| `locale` | 否 | 返回语言:`zh-CN``en-US``ja-JP` |
| `page_size` | 否 | 分页大小 |
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
@@ -101,6 +106,7 @@ lark-cli approval instances initiated \
- **这是定位“我发起的审批实例”的首选命令**:如果你的目标是撤回、抄送、查看某个已发起审批,优先从这里拿 `instance_code`
- **优先用 `definition_code` 缩小范围**:当你已知审批定义时,先筛掉无关实例,可显著提升可读性。
- **按时间排查时使用 `start_timestamp` / `end_timestamp`**:这两个值都是秒级时间戳,用于按发起时间缩小结果范围。
- **结果很多时优先 `--format table`**:适合人工快速浏览。
- **`count` 只在第一页返回**:做分页处理时不要假设后续页还会带总数。
- **`instance_status` 可直接判断下一步**:例如状态为 `1` 时通常可继续查看详情或考虑撤回,状态为 `4` 表示已经撤销,无需重复撤回。

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